diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md
index c9b4b4779..52d6460f2 100644
--- a/webui/CHANGELOG.md
+++ b/webui/CHANGELOG.md
@@ -6,11 +6,20 @@ This change log covers only the frontend library (webui) of Open VSX.
### Added
+- Add a home page with hero search, popular searches, a category browser, curated extension rows and get-involved cards
+- Add a dedicated search page under `/search` with query, category, sort field and sort order synced to the URL
+- Add global keyboard shortcuts
+- Add keyboard navigation of search results: `↑`/`↓`
+- Add a structured footer
+- Add scroll-to-top on forward navigation
- Support searching users and managing their roles in the admin dashboard ([#1847](https://github.com/eclipse-openvsx/openvsx/pull/1847))
- Added an extension details page to admin dashboard and user settings ([#1939](https://github.com/eclipse-openvsx/openvsx/pull/1939))
### Changed
+- Redesign the web UI: new navbar with integrated search field, new theme, extension cards, category pills and page layout
+- Improve accessibility: visible focus outlines on interactive controls
+- Morph the hero search into the navbar search field using the View Transitions API
- Token display in generate-token dialog now uses a masked input with show/hide toggle and copy button ([#1966](https://github.com/eclipse-openvsx/openvsx/pull/1966)
- Migrate admin dashboard to use `@tanstack/react-query` ([#1917](https://github.com/eclipse-openvsx/openvsx/pull/1917)
- Replace formatting from `stylistic` with `prettier` ([#1916](https://github.com/eclipse-openvsx/openvsx/pull/1916))
diff --git a/webui/index.html b/webui/index.html
index 0a097be03..821ee92b4 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3,9 +3,6 @@
-
-
-
diff --git a/webui/package.json b/webui/package.json
index c32d2edf0..eb43f9128 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -41,6 +41,9 @@
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+ "@fontsource-variable/geist": "^5.2.9",
+ "@fontsource-variable/geist-mono": "^5.2.8",
+ "@fontsource/roboto": "^5.2.10",
"@mdit/plugin-alert": "^0.22.3",
"@mui/base": "^5.0.0-beta.9",
"@mui/icons-material": "^5.15.14",
diff --git a/webui/src/components/banner.tsx b/webui/src/components/banner.tsx
index 9b199555d..f821f6a60 100644
--- a/webui/src/components/banner.tsx
+++ b/webui/src/components/banner.tsx
@@ -9,53 +9,75 @@
********************************************************************************/
import { FunctionComponent, PropsWithChildren } from 'react';
-import { Paper, Grid, Button, Collapse } from '@mui/material';
+import { Box, Button, Collapse, IconButton } from '@mui/material';
+import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
+import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
+import CloseIcon from '@mui/icons-material/Close';
+
+const VARIANTS = {
+ info: { Icon: InfoOutlinedIcon, bg: 'accentSoft', accent: 'secondary.light' },
+ warning: { Icon: WarningAmberRoundedIcon, bg: 'warningSoft', accent: 'warningAccent' }
+} as const;
export const Banner: FunctionComponent> = props => {
- const cardColor = props.theme === 'dark' ? '#fff' : '#000';
- const cardBackground = `${props.color}.${props.theme}`;
+ const { color = 'info', open, showDismissButton, dismissButtonLabel, dismissButtonOnClick, children } = props;
+ const { Icon, bg, accent } = VARIANTS[color];
return (
-
-
+
-
-
- {props.children}
-
- {props.showDismissButton && (
-
+
+
+ {children}
+
+
+ {showDismissButton &&
+ (dismissButtonLabel ? (
+ ({
+ flexShrink: 0,
+ color: accent,
+ px: 2,
+ py: 0.5,
+ fontWeight: 600,
+ fontSize: '0.8125rem',
whiteSpace: 'nowrap',
- alignSelf: 'center',
- display: 'flex',
- justifyContent: 'center',
- flexBasis: '100%'
- }}>
-
- {props.dismissButtonLabel ?? 'Close'}
-
-
- )}
-
-
+ borderRadius: `${theme.shape.borderRadius}px`,
+ '&:hover': { backgroundColor: 'color-mix(in srgb, currentColor 8%, transparent)' }
+ })}>
+ {dismissButtonLabel}
+
+ ) : (
+
+
+
+ ))}
+
);
};
@@ -66,5 +88,4 @@ interface BannerProps {
dismissButtonLabel?: string;
dismissButtonOnClick?: () => void;
color?: 'info' | 'warning';
- theme?: 'light' | 'dark';
}
diff --git a/webui/src/components/categories.ts b/webui/src/components/categories.ts
new file mode 100644
index 000000000..8beda38e8
--- /dev/null
+++ b/webui/src/components/categories.ts
@@ -0,0 +1,65 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent } from 'react';
+import { SvgIconProps } from '@mui/material';
+import DataObjectIcon from '@mui/icons-material/DataObject';
+import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
+import SpellcheckIcon from '@mui/icons-material/Spellcheck';
+import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import PaletteIcon from '@mui/icons-material/Palette';
+import BugReportIcon from '@mui/icons-material/BugReport';
+import AccountTreeIcon from '@mui/icons-material/AccountTree';
+import KeyboardIcon from '@mui/icons-material/Keyboard';
+import ExtensionIcon from '@mui/icons-material/Extension';
+import BarChartIcon from '@mui/icons-material/BarChart';
+import TranslateIcon from '@mui/icons-material/Translate';
+import InsightsIcon from '@mui/icons-material/Insights';
+import ModelTrainingIcon from '@mui/icons-material/ModelTraining';
+import MenuBookIcon from '@mui/icons-material/MenuBook';
+import GridViewIcon from '@mui/icons-material/GridView';
+import { CATEGORIES, ExtensionCategory } from '../extension-registry-types';
+
+export const DefaultCategoryIcon: FunctionComponent = GridViewIcon;
+
+// The category list itself lives in extension-registry-types; this record is
+// compile-checked to stay exhaustive when categories are added or removed.
+export const CATEGORY_ICONS: Record> = {
+ AI: AutoAwesomeIcon,
+ 'Programming Languages': DataObjectIcon,
+ Snippets: ContentCopyIcon,
+ Linters: SpellcheckIcon,
+ Themes: PaletteIcon,
+ Debuggers: BugReportIcon,
+ Formatters: FormatAlignLeftIcon,
+ Keymaps: KeyboardIcon,
+ 'SCM Providers': AccountTreeIcon,
+ Other: GridViewIcon,
+ 'Extension Packs': ExtensionIcon,
+ 'Language Packs': TranslateIcon,
+ 'Data Science': InsightsIcon,
+ 'Machine Learning': ModelTrainingIcon,
+ Visualization: BarChartIcon,
+ Notebooks: MenuBookIcon
+};
+
+const SORTED_CATEGORIES: ExtensionCategory[] = [...CATEGORIES].sort((a, b) => {
+ if (a === 'Other') return 1;
+ if (b === 'Other') return -1;
+ return a.localeCompare(b);
+});
+
+export function useCategories(): ExtensionCategory[] {
+ return SORTED_CATEGORIES;
+}
diff --git a/webui/src/components/category-card.tsx b/webui/src/components/category-card.tsx
new file mode 100644
index 000000000..05265ebf6
--- /dev/null
+++ b/webui/src/components/category-card.tsx
@@ -0,0 +1,63 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent } from 'react';
+import { Box, ButtonBase, SvgIconProps, Typography } from '@mui/material';
+import { styled } from '@mui/material/styles';
+import { cardHoverLift, cardSurface, focusOutline } from './page-primitives';
+
+const Root = styled(ButtonBase)(({ theme }) => ({
+ ...cardSurface(theme),
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ textAlign: 'left',
+ overflow: 'hidden',
+ flexGrow: 1,
+ maxWidth: '16rem',
+ gap: '0.625rem',
+ padding: '0.625rem 0.875rem 0.625rem 0.75rem',
+ color: theme.palette.text.primary,
+ transition: 'border-color 0.15s, box-shadow 0.15s, transform 0.15s',
+ ...cardHoverLift(theme),
+ ...focusOutline(theme),
+ '& .MuiTouchRipple-root': { color: theme.palette.secondary.main }
+}));
+
+export interface CategoryCardProps {
+ label: string;
+ icon: FunctionComponent;
+ onClick: () => void;
+}
+
+export const CategoryCard: FunctionComponent = ({ label, icon: Icon, onClick }) => (
+
+
+
+
+
+ {label}
+
+
+);
diff --git a/webui/src/components/category-list-item.tsx b/webui/src/components/category-list-item.tsx
new file mode 100644
index 000000000..b5cb70832
--- /dev/null
+++ b/webui/src/components/category-list-item.tsx
@@ -0,0 +1,64 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent } from 'react';
+import { ButtonBase, SvgIconProps } from '@mui/material';
+import { styled } from '@mui/material/styles';
+import { focusOutline } from './page-primitives';
+
+const Root = styled(ButtonBase, {
+ shouldForwardProp: prop => prop !== 'isSelected'
+})<{ isSelected: boolean }>(({ theme, isSelected }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ gap: '0.5rem',
+ width: '100%',
+ textAlign: 'left',
+ padding: '0.4375rem 0.625rem',
+ borderRadius: theme.shape.borderRadius,
+ overflow: 'hidden',
+ fontSize: '0.8125rem',
+ fontWeight: isSelected ? 600 : 400,
+ color: isSelected ? theme.palette.secondary.light : theme.palette.text.secondary,
+ backgroundColor: isSelected ? theme.palette.accentSoft : 'transparent',
+ marginBottom: '1px',
+ fontFamily: 'inherit',
+ transition: 'background 0.14s, color 0.14s',
+ '&:hover': isSelected
+ ? {}
+ : {
+ backgroundColor: theme.palette.surface3,
+ color: theme.palette.text.primary
+ },
+ ...focusOutline(theme)
+}));
+
+export interface CategoryListItemProps {
+ label: string;
+ icon: FunctionComponent;
+ isSelected: boolean;
+ onClick: () => void;
+}
+
+export const CategoryListItem: FunctionComponent = ({
+ label,
+ icon: Icon,
+ isSelected,
+ onClick
+}) => (
+
+
+ {label}
+
+);
diff --git a/webui/src/components/category-pill.tsx b/webui/src/components/category-pill.tsx
new file mode 100644
index 000000000..bf260e67f
--- /dev/null
+++ b/webui/src/components/category-pill.tsx
@@ -0,0 +1,64 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, useEffect, useRef } from 'react';
+import { ButtonBase, SvgIconProps } from '@mui/material';
+import { styled } from '@mui/material/styles';
+import { accentHover, focusOutline } from './page-primitives';
+
+const Root = styled(ButtonBase, {
+ shouldForwardProp: prop => prop !== 'isSelected'
+})<{ isSelected?: boolean }>(({ theme, isSelected }) => ({
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4375rem',
+ flexShrink: 0,
+ overflow: 'hidden',
+ backgroundColor: isSelected ? theme.palette.accentSoft : theme.palette.surface2,
+ border: `1px solid ${isSelected ? theme.palette.secondary.main : theme.palette.divider}`,
+ color: isSelected ? theme.palette.secondary.light : theme.palette.text.secondary,
+ fontSize: '0.8125rem',
+ fontWeight: isSelected ? 600 : 500,
+ padding: '0.4375rem 0.8125rem',
+ borderRadius: '999px',
+ whiteSpace: 'nowrap',
+ fontFamily: 'inherit',
+ transition: 'border-color 0.14s, color 0.14s',
+ ...(isSelected ? {} : accentHover(theme)),
+ ...focusOutline(theme)
+}));
+
+export interface CategoryPillProps {
+ label: string;
+ icon: FunctionComponent;
+ isSelected?: boolean;
+ onClick: () => void;
+}
+
+export const CategoryPill: FunctionComponent = ({ label, icon: Icon, isSelected, onClick }) => {
+ const ref = useRef(null);
+
+ // Keep the selected pill visible when the row overflows (deep links, home tiles).
+ useEffect(() => {
+ if (isSelected) {
+ ref.current?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' });
+ }
+ }, [isSelected]);
+
+ return (
+
+
+ {label}
+
+ );
+};
diff --git a/webui/src/components/extension-card.tsx b/webui/src/components/extension-card.tsx
new file mode 100644
index 000000000..476f72960
--- /dev/null
+++ b/webui/src/components/extension-card.tsx
@@ -0,0 +1,232 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { forwardRef, FunctionComponent, memo } from 'react';
+import { Link as RouteLink } from 'react-router-dom';
+import { Paper, Typography, Box, Fade, Skeleton } from '@mui/material';
+import { CSSObject, styled, Theme } from '@mui/material/styles';
+import SaveAltIcon from '@mui/icons-material/SaveAlt';
+import { ExtensionDetailRoutes } from '../pages/extension-detail/extension-detail-routes';
+import { SearchEntry } from '../extension-registry-types';
+import { ExtensionIcon } from './extension/extension-icon';
+import { ExtensionRatingStars } from '../pages/extension-detail/extension-rating-stars';
+import { createRoute, formatCompactNumber } from '../utils';
+import { MONO_FONT } from '../default/theme';
+import { GridItemProps } from '../hooks/use-grid-cursor';
+import { cardHoverLift, cardSurface, focusRing } from './page-primitives';
+
+// Shared surface + footprint so the card and its skeleton occupy identical space.
+const cardLayout = (theme: Theme): CSSObject => ({
+ ...cardSurface(theme),
+ padding: '1.375rem 1rem',
+ [theme.breakpoints.down('sm')]: { padding: '0.875rem 0.625rem' },
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ height: '100%',
+ minHeight: '12.875rem'
+});
+
+const CardRoot = styled(Paper)(({ theme }) => ({
+ ...cardLayout(theme),
+ textAlign: 'center',
+ cursor: 'pointer',
+ transition: 'border-color 0.15s, box-shadow 0.15s, transform 0.15s',
+ ...cardHoverLift(theme),
+ // Keyboard focus ring mirrors the search field's :focus-within style.
+ // Ring when the card link is keyboard-focused, or when it is the grid
+ // cursor and the cursor is visible (see useGridCursor).
+ 'a:focus-visible &, [data-cursor-visible] a[data-active] &': focusRing(theme)
+}));
+
+const SkeletonRoot = styled(Paper)(({ theme }) => cardLayout(theme));
+
+// Only the unknown parts are skeletons; the stars' empty state looks the same loaded or not.
+const SkeletonContent: FunctionComponent = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
+
+/** Loading placeholder matching {@link ExtensionCard}'s footprint. */
+export const ExtensionCardSkeleton: FunctionComponent = () => (
+
+
+
+);
+
+// The grid cursor props are optional: cards also render outside a cursor grid
+// (curated sections, namespace detail).
+export interface ExtensionCardProps extends Partial> {
+ /**
+ * The extension, or `undefined` for a loading skeleton. Keep a stable key
+ * across the swap so the fade plays once instead of restarting.
+ */
+ extension?: SearchEntry;
+ /** Delay before the card fades in, so grids can stagger their cards. */
+ fadeDelayMs?: number;
+ /** When false, the card shows immediately without its entrance fade (e.g. restored from cache on back-nav). */
+ appear?: boolean;
+}
+
+export const ExtensionCard = memo(
+ forwardRef(function ExtensionCard(
+ { extension, fadeDelayMs = 0, appear = true, ...linkProps },
+ ref
+ ) {
+ const title = extension?.displayName ?? extension?.name;
+ const downloadCount = extension ? formatCompactNumber(extension.downloadCount ?? 0) : undefined;
+
+ // One Fade over both states so it runs once and carries through the skeleton → card swap.
+ return (
+
+
+ {extension ? (
+
+
+
+
+
+
+ {title}
+
+
+
+ {extension.namespace}
+
+
+ {extension.version}
+
+
+
+
+
+
+ {downloadCount !== '0' && (
+
+
+ {downloadCount}
+
+ )}
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+ })
+);
diff --git a/webui/src/components/extension-list.tsx b/webui/src/components/extension-list.tsx
new file mode 100644
index 000000000..0e63a2d54
--- /dev/null
+++ b/webui/src/components/extension-list.tsx
@@ -0,0 +1,101 @@
+/********************************************************************************
+ * Copyright (c) 2019 TypeFox and others
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, useContext, useEffect, useMemo, useRef } from 'react';
+import InfiniteScroll from 'react-infinite-scroller';
+import { Box } from '@mui/material';
+import { ExtensionCard } from './extension-card';
+import { ExtensionFilter } from '../extension-registry-service';
+import { useExtensionResultsCursor } from '../hooks/use-extension-results-cursor';
+import { useInfiniteSearch } from '../hooks/use-infinite-search';
+import { MainContext } from '../context';
+
+export const ExtensionList: FunctionComponent = ({ filter, onUpdate }) => {
+ const { handleError } = useContext(MainContext);
+ const { data, error, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteSearch(filter);
+
+ const extensions = useMemo(() => data?.pages.flatMap(page => page.extensions) ?? [], [data]);
+ const totalSize = data?.pages[0]?.totalSize ?? 0;
+ const grid = useExtensionResultsCursor(extensions.length);
+
+ // Report the result count to the parent (shown in the search header).
+ useEffect(() => {
+ onUpdate(totalSize);
+ }, [totalSize, onUpdate]);
+
+ // Surface fetch failures through the app's error handler.
+ useEffect(() => {
+ if (error) {
+ handleError(error);
+ }
+ }, [error, handleError]);
+
+ // Fresh filter: put the cursor back on the first card so Enter opens it.
+ useEffect(() => {
+ grid.reset();
+ }, [filter.query, filter.category, filter.sortBy, filter.sortOrder, grid.reset]);
+
+ const pageSize = filter.size;
+ const loading = isLoading || isFetchingNextPage;
+
+ // Cards already in the query cache when this list mounts (e.g. returning via
+ // browser back) render in place without replaying the entrance fade, so the
+ // page looks as if it was never unmounted. Cards that load afterwards still fade.
+ const restoredCount = useRef(loading ? 0 : extensions.length).current;
+
+ // Index-keyed slots (the list only appends): loading slots render as
+ // skeletons that become cards in place, so the fade isn't restarted.
+ const slotCount = extensions.length + (loading ? pageSize : 0);
+ const cards = Array.from({ length: slotCount }, (_, idx) => {
+ const extension = extensions[idx];
+ return (
+ = restoredCount}
+ fadeDelayMs={Math.min(idx % pageSize, 5) * 200}
+ {...(extension ? grid.itemProps(idx) : {})}
+ />
+ );
+ });
+
+ return (
+ {
+ if (hasNextPage && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ }}
+ hasMore={hasNextPage && !isFetchingNextPage}
+ threshold={200}>
+
+ {cards}
+
+
+ );
+};
+
+export interface ExtensionListProps {
+ filter: ExtensionFilter;
+ onUpdate: (resultNumber: number) => void;
+}
diff --git a/webui/src/components/extension-searchfield.tsx b/webui/src/components/extension-searchfield.tsx
new file mode 100644
index 000000000..eaa5a0de0
--- /dev/null
+++ b/webui/src/components/extension-searchfield.tsx
@@ -0,0 +1,171 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { ChangeEvent, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useId, useRef } from 'react';
+import SearchIcon from '@mui/icons-material/Search';
+import ClearIcon from '@mui/icons-material/Close';
+import { IconButton, InputBase, InputBaseComponentProps, Box } from '@mui/material';
+import { alpha, styled } from '@mui/material/styles';
+import { MONO_FONT } from '../default/theme';
+import { focusRing } from './page-primitives';
+
+interface ExtensionSearchfieldProps {
+ onSearchChanged: (s: string) => void;
+ onSearchSubmit?: (s: string) => void;
+ searchQuery?: string;
+ placeholder: string;
+ hideIconButton?: boolean;
+ error?: boolean;
+ autoFocus?: boolean;
+ viewTransitionName?: string;
+ inputProps?: InputBaseComponentProps;
+}
+
+const SearchWrap = styled(Box, {
+ shouldForwardProp: prop => prop !== 'hasError'
+})<{ hasError?: boolean }>(({ theme, hasError }) => ({
+ flex: 2,
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.625rem',
+ border: hasError ? '2px solid' : '1px solid',
+ borderColor: hasError ? theme.palette.error.main : theme.palette.divider,
+ borderRadius: '11px',
+ height: '2.8125rem',
+ padding: '0 0.8125rem',
+ backgroundColor: alpha(theme.palette.surface2, 0.7),
+ backdropFilter: 'blur(2px)',
+ transition: 'border-color 0.18s, box-shadow 0.18s',
+ '&:focus-within': focusRing(theme)
+}));
+
+const MonoSlash = styled('span')(({ theme }) => ({
+ fontFamily: MONO_FONT,
+ color: theme.palette.secondary.light,
+ fontSize: '1.0625rem',
+ lineHeight: 1,
+ flexShrink: 0,
+ userSelect: 'none'
+}));
+
+const SearchInput = styled(InputBase)(({ theme }) => ({
+ flex: 1,
+ fontFamily: MONO_FONT,
+ fontSize: '0.9375rem',
+ color: theme.palette.text.primary,
+ // iOS Safari zooms the viewport on focus when the field's font-size is < 16px;
+ // keep it at 16px on mobile to suppress that (desktop stays compact at 15px).
+ [theme.breakpoints.down('sm')]: { fontSize: '1rem' },
+ '& input::placeholder': { color: theme.palette.text.primary, opacity: 0.7 },
+ '& input::-webkit-search-cancel-button': { display: 'none' }
+}));
+
+export const ExtensionSearchfield = forwardRef(
+ (props: ExtensionSearchfieldProps, ref: ForwardedRef) => {
+ const inputRef = useRef(null);
+ const inputId = useId();
+
+ // Keep the forwarded ref and the internal ref (used by the clear button) in sync.
+ const setInputRef = useCallback(
+ (node: HTMLInputElement | null) => {
+ inputRef.current = node;
+ if (typeof ref === 'function') {
+ ref(node);
+ } else if (ref) {
+ ref.current = node;
+ }
+ },
+ [ref]
+ );
+
+ const handleSearchChange = (event: ChangeEvent) => {
+ props.onSearchChanged(event.target.value);
+ };
+
+ const handleSearchButtonClick = () => {
+ if (props.onSearchSubmit) {
+ props.onSearchSubmit(props.searchQuery ?? '');
+ }
+ };
+
+ const handleClear = () => {
+ props.onSearchChanged('');
+ inputRef.current?.focus();
+ };
+
+ // Merged into inputProps because InputBase spreads inputProps after its own
+ // onKeyDown, so a caller-supplied handler would silently replace Enter-submit.
+ const handleKeyDown = (event: KeyboardEvent) => {
+ props.inputProps?.onKeyDown?.(event);
+ if (event.key === 'Enter' && props.onSearchSubmit && !event.defaultPrevented) {
+ props.onSearchSubmit(props.searchQuery ?? '');
+ }
+ };
+
+ return (
+
+ /
+
+
+ Search for Name, Tags or Description
+
+ {props.searchQuery && (
+
+
+
+ )}
+ {!props.hideIconButton && (
+
+
+
+ )}
+
+ );
+ }
+);
+
+ExtensionSearchfield.displayName = 'ExtensionSearchfield';
diff --git a/webui/src/components/extension/extension-icon.tsx b/webui/src/components/extension/extension-icon.tsx
index 533d31b5a..8078e87f1 100644
--- a/webui/src/components/extension/extension-icon.tsx
+++ b/webui/src/components/extension/extension-icon.tsx
@@ -12,18 +12,26 @@
*****************************************************************************/
import { FunctionComponent, useContext } from 'react';
-import { Box, SxProps, Theme } from '@mui/material';
+import { Box, Skeleton, SxProps, Theme } from '@mui/material';
import { MainContext } from '../../context';
import { Extension, SearchEntry } from '../../extension-registry-types';
import { useExtensionIcon } from './use-extension-icon';
-/**
- * Renders an extension's icon, falling back to the configured default icon
- * while the real one loads or when the extension has none.
- */
+/** Renders an extension's icon: a skeleton while loading, then the icon or the configured default. */
export const ExtensionIcon: FunctionComponent = ({ extension, alt, sx }) => {
const { pageSettings } = useContext(MainContext);
- const icon = useExtensionIcon(extension);
+ const { data: icon, isLoading } = useExtensionIcon(extension);
+
+ if (isLoading) {
+ // Reset the Skeleton's default height so `aspectRatio` squares it from
+ // the width; an explicit height in `sx` still wins.
+ return (
+
+ );
+ }
return (
{
+export const useExtensionIcon = (extension: Extension | SearchEntry) => {
const { service } = useContext(MainContext);
const targetPlatform = 'targetPlatform' in extension ? extension.targetPlatform : undefined;
- const { data } = useQuery({
+ return useQuery({
queryKey: [
'extension-icon',
extension.namespace,
@@ -40,27 +39,6 @@ export const useExtensionIcon = (extension: Extension | SearchEntry): string | u
const icon = await service.getExtensionIcon(controllerFromSignal(signal), extension);
// useQuery forbids `undefined`; normalise the "no icon" case to null.
return icon ?? null;
- },
- gcTime: 0,
- staleTime: 0
- });
-
- const previousUrl = useRef(null);
- useEffect(() => {
- if (previousUrl.current && previousUrl.current !== data) {
- URL.revokeObjectURL(previousUrl.current);
}
- previousUrl.current = data ?? null;
- }, [data]);
-
- useEffect(
- () => () => {
- if (previousUrl.current) {
- URL.revokeObjectURL(previousUrl.current);
- }
- },
- []
- );
-
- return data ?? undefined;
+ });
};
diff --git a/webui/src/components/kbd-key.tsx b/webui/src/components/kbd-key.tsx
new file mode 100644
index 000000000..c4b92da35
--- /dev/null
+++ b/webui/src/components/kbd-key.tsx
@@ -0,0 +1,50 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, PropsWithChildren } from 'react';
+import { Box } from '@mui/material';
+import { MONO_FONT } from '../default/theme';
+
+export const KbdKey: FunctionComponent = ({ children }) => (
+
+ {children}
+
+);
diff --git a/webui/src/components/openvsx-mark.tsx b/webui/src/components/openvsx-mark.tsx
new file mode 100644
index 000000000..24d44d862
--- /dev/null
+++ b/webui/src/components/openvsx-mark.tsx
@@ -0,0 +1,21 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { CSSProperties, FunctionComponent } from 'react';
+
+export const OpenVsxMark: FunctionComponent<{ style?: CSSProperties }> = ({ style }) => (
+
+
+
+
+);
diff --git a/webui/src/components/page-primitives.tsx b/webui/src/components/page-primitives.tsx
new file mode 100644
index 000000000..27a4101c9
--- /dev/null
+++ b/webui/src/components/page-primitives.tsx
@@ -0,0 +1,88 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { Box, Typography } from '@mui/material';
+import { alpha, styled, Theme } from '@mui/material/styles';
+
+/** Max width of the centered content column shared by every page section. */
+export const CONTENT_MAX_WIDTH = 1320;
+
+/**
+ * Horizontally-centered content column with responsive gutters. The single
+ * source of truth for page width so sections stay aligned across the app.
+ */
+export const Section = styled(Box)(({ theme }) => ({
+ maxWidth: CONTENT_MAX_WIDTH,
+ marginInline: 'auto',
+ paddingInline: '1.75rem',
+ [theme.breakpoints.down('sm')]: {
+ paddingInline: '1rem'
+ }
+}));
+
+/** Small uppercase label used to head sections, columns and sidebars. */
+export const Eyebrow = styled(Typography)(({ theme }) => ({
+ fontSize: '0.75rem',
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ letterSpacing: '0.08em',
+ color: theme.palette.text.disabled
+})) as typeof Typography;
+
+/**
+ * Elevated surface shared by cards (extensions, categories, panels): paper
+ * background, hairline border and the card corner radius from the theme.
+ */
+export const cardSurface = (theme: Theme) => ({
+ backgroundColor: theme.palette.background.paper,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadiusCard
+});
+
+/** Accent focus ring shared by the search fields and card links. */
+export const focusRing = (theme: Theme, extraShadow?: string) => ({
+ borderColor: theme.palette.secondary.main,
+ boxShadow: `0 0 0 3px ${alpha(theme.palette.secondary.main, 0.16)}${extraShadow ? `, ${extraShadow}` : ''}`
+});
+
+/**
+ * Keyboard-focus outline for buttons, pills and links. ButtonBase resets the
+ * native outline, so anything built on it needs this to be reachable by keyboard.
+ */
+export const focusOutline = (theme: Theme) => ({
+ '&.Mui-focusVisible, &:focus-visible': {
+ outline: `2px solid ${theme.palette.secondary.main}`,
+ outlineOffset: '2px'
+ }
+});
+
+/** Hover treatment for chips and pills: accent border and text color. Suppressed on touch devices. */
+export const accentHover = (theme: Theme) => ({
+ '@media (hover: hover)': {
+ '&:hover': {
+ borderColor: theme.palette.secondary.main,
+ color: theme.palette.secondary.light
+ }
+ }
+});
+
+/** Hover treatment for interactive cards: accent border, shadow and lift. Suppressed on touch devices. */
+export const cardHoverLift = (theme: Theme) => ({
+ '@media (hover: hover)': {
+ '&:hover': {
+ borderColor: theme.palette.secondary.main,
+ boxShadow: 'var(--shadow)',
+ transform: 'translateY(-2px)'
+ }
+ }
+});
diff --git a/webui/src/components/sanitized-markdown.tsx b/webui/src/components/sanitized-markdown.tsx
index 09228c535..98a49513d 100644
--- a/webui/src/components/sanitized-markdown.tsx
+++ b/webui/src/components/sanitized-markdown.tsx
@@ -18,6 +18,11 @@ import linkIcon from './link-icon';
import { useLocation } from 'react-router-dom';
const Markdown = styled('div')(({ theme }: { theme: Theme }) => ({
+ // The container owns the leading gap; the first block shouldn't add its own
+ // heading/paragraph margin on top of it.
+ '& > :first-of-type': {
+ marginTop: 0
+ },
'& a': {
textDecoration: 'none',
color: theme.palette.secondary.main,
@@ -66,6 +71,8 @@ const Markdown = styled('div')(({ theme }: { theme: Theme }) => ({
'& table': {
borderCollapse: 'collapse',
borderSpacing: 0,
+ display: 'block',
+ overflowX: 'auto',
'& tr, & td, & th': {
border: '1px solid #ddd'
},
diff --git a/webui/src/components/shortcuts-modal.tsx b/webui/src/components/shortcuts-modal.tsx
new file mode 100644
index 000000000..25ad5d0b4
--- /dev/null
+++ b/webui/src/components/shortcuts-modal.tsx
@@ -0,0 +1,72 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent } from 'react';
+import { Dialog, DialogTitle, DialogContent, Box, Typography, IconButton } from '@mui/material';
+import CloseIcon from '@mui/icons-material/Close';
+import { KbdKey } from './kbd-key';
+import { useKeyboardShortcuts } from '../context/keyboard-shortcuts-context';
+
+interface ShortcutsModalProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const ShortcutsModal: FunctionComponent = ({ open, onClose }) => {
+ const { shortcuts } = useKeyboardShortcuts();
+
+ return (
+
+
+ Keyboard shortcuts
+
+
+
+
+ {/* This dialog exists to display keys, so it re-shows the globally touch-hidden chips. */}
+
+ {shortcuts.map((s, i) => (
+
+ {s.label}
+ {s.key}
+
+ ))}
+
+
+ );
+};
diff --git a/webui/src/context/extension-tint-context.tsx b/webui/src/context/extension-tint-context.tsx
new file mode 100644
index 000000000..6b48ff499
--- /dev/null
+++ b/webui/src/context/extension-tint-context.tsx
@@ -0,0 +1,50 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { createContext, FunctionComponent, ReactNode, useContext, useMemo, useState } from 'react';
+
+/**
+ * The tint region an extension detail page declares while mounted. The page
+ * only describes it; the nav bar compares the depth against its own scroll
+ * position to decide when to wear the color.
+ */
+export interface ExtensionTint {
+ // Gallery color the nav wears while the region backs it, flipping its
+ // content to the contrast color. Null for default-colored bands, which
+ // keep the nav on theme colors.
+ color: string | null;
+ // Document offset where the region ends; scrolled past it the nav returns
+ // to theme colors.
+ depth: number;
+}
+
+const ExtensionTintContext = createContext<{
+ tint: ExtensionTint | null;
+ setTint: (tint: ExtensionTint | null) => void;
+}>({ tint: null, setTint: () => {} });
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useExtensionTint(): ExtensionTint | null {
+ return useContext(ExtensionTintContext).tint;
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useSetExtensionTint(): (tint: ExtensionTint | null) => void {
+ return useContext(ExtensionTintContext).setTint;
+}
+
+export const ExtensionTintProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => {
+ const [tint, setTint] = useState(null);
+ const value = useMemo(() => ({ tint, setTint }), [tint]);
+ return {children} ;
+};
diff --git a/webui/src/context/keyboard-shortcuts-context.tsx b/webui/src/context/keyboard-shortcuts-context.tsx
new file mode 100644
index 000000000..89de8278e
--- /dev/null
+++ b/webui/src/context/keyboard-shortcuts-context.tsx
@@ -0,0 +1,153 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import {
+ createContext,
+ FunctionComponent,
+ PropsWithChildren,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState
+} from 'react';
+
+export interface ShortcutInfo {
+ key: string;
+ label: string;
+ order: number;
+}
+
+interface ShortcutEntry extends ShortcutInfo {
+ callback: () => void;
+}
+
+interface KeyboardShortcutsContextValue {
+ shortcuts: ShortcutInfo[];
+ register: (entry: ShortcutEntry) => void;
+ unregister: (key: string) => void;
+}
+
+const KeyboardShortcutsContext = createContext({
+ shortcuts: [],
+ register: () => {},
+ unregister: () => {}
+});
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useKeyboardShortcuts = () => useContext(KeyboardShortcutsContext);
+
+export const KeyboardShortcutsProvider: FunctionComponent = ({ children }) => {
+ const registryRef = useRef>(new Map());
+ const [shortcuts, setShortcuts] = useState([]);
+
+ const sync = () =>
+ setShortcuts(
+ Array.from(registryRef.current.values())
+ .map(({ key, label, order }) => ({ key, label, order }))
+ .sort((a, b) => a.order - b.order)
+ );
+
+ const register = useCallback((entry: ShortcutEntry) => {
+ registryRef.current.set(entry.key, entry);
+ sync();
+ }, []);
+
+ const unregister = useCallback((key: string) => {
+ registryRef.current.delete(key);
+ sync();
+ }, []);
+
+ // Chord detection: only fire a shortcut when the key was pressed in isolation.
+ //
+ // Two subtleties drive the implementation:
+ //
+ // 1. Key identity: Shift changes e.key (Shift+/ → e.key='?'), but e.code is
+ // always the physical key ('Slash'). We track by e.code and store e.key at
+ // keydown so the correct character is available at keyup even if Shift was
+ // released in between.
+ //
+ // 2. Browser defaults: some browsers handle '/' as a find shortcut on keydown,
+ // before our keyup fires. When a solo registered key goes down we eagerly
+ // call e.preventDefault() to block that, then fire the callback on keyup.
+ //
+ // 3. Missed keyups: browsers don't always deliver keyup. On macOS a held Meta
+ // swallows the keyup of the other key (Cmd+C, Cmd+T, …), and losing window
+ // focus (Alt+Tab, opening a tab) sends the keyup elsewhere. Either leaves
+ // stale entries in `pressed`/`dirty` that permanently break detection, so we
+ // flush the transient state on window blur and when Meta is released.
+ useEffect(() => {
+ const pressed = new Map(); // code → key captured at keydown
+ const dirty = new Set(); // codes involved in a chord
+
+ const reset = () => {
+ pressed.clear();
+ dirty.clear();
+ };
+
+ // Ignore keys typed into inputs and keys pressed while a popup owns focus
+ // (MUI selects/menus/dialogs render as listbox/menu/dialog roles, not native elements).
+ const shouldIgnore = (target: EventTarget | null) => {
+ const el = target as HTMLElement;
+ return (
+ el.tagName === 'INPUT' ||
+ el.tagName === 'TEXTAREA' ||
+ el.tagName === 'SELECT' ||
+ el.isContentEditable ||
+ Boolean(el.closest?.('[role="listbox"], [role="menu"], [role="dialog"]'))
+ );
+ };
+
+ const onKeydown = (e: KeyboardEvent) => {
+ if (e.repeat || e.key === 'Shift') return;
+ if (pressed.size > 0) {
+ pressed.forEach((_, code) => dirty.add(code));
+ dirty.add(e.code);
+ } else if (!shouldIgnore(e.target) && registryRef.current.has(e.key)) {
+ e.preventDefault();
+ }
+ pressed.set(e.code, e.key);
+ };
+
+ const onKeyup = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') return;
+ // Meta chords swallow the keyup of the other key, so once Meta is
+ // released flush anything still held to avoid stale state.
+ if (e.key === 'Meta') return reset();
+ const isDirty = dirty.has(e.code);
+ const key = pressed.get(e.code) ?? e.key;
+ pressed.delete(e.code);
+ dirty.delete(e.code);
+ if (isDirty || shouldIgnore(e.target)) return;
+ const entry = registryRef.current.get(key);
+ if (entry) entry.callback();
+ };
+
+ document.addEventListener('keydown', onKeydown);
+ document.addEventListener('keyup', onKeyup);
+ window.addEventListener('blur', reset);
+ return () => {
+ document.removeEventListener('keydown', onKeydown);
+ document.removeEventListener('keyup', onKeyup);
+ window.removeEventListener('blur', reset);
+ };
+ }, []);
+
+ return (
+ ({ shortcuts, register, unregister }), [shortcuts, register, unregister])}>
+ {children}
+
+ );
+};
diff --git a/webui/src/context/search/page-search-bar-context.tsx b/webui/src/context/search/page-search-bar-context.tsx
new file mode 100644
index 000000000..657289297
--- /dev/null
+++ b/webui/src/context/search/page-search-bar-context.tsx
@@ -0,0 +1,110 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import {
+ createContext,
+ FunctionComponent,
+ ReactNode,
+ RefObject,
+ useCallback,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState
+} from 'react';
+
+/**
+ * Tracks whether a page-level search bar (e.g. the home hero) is active — the
+ * nav bar derives its own field's visibility from it.
+ */
+
+export interface PageSearchBarValue {
+ // A page-level search bar is registered — the nav bar hides its own field.
+ hasPageSearchBar: boolean;
+ // Synchronous read — `hasPageSearchBar` lags one render when a bar (un)registers
+ // in the same commit that emits a focus signal.
+ isPageSearchBarActive: () => boolean;
+ registerPageSearchBar: () => () => void;
+}
+
+const PageSearchBarContext = createContext({
+ hasPageSearchBar: false,
+ isPageSearchBarActive: () => false,
+ registerPageSearchBar: () => () => {}
+});
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function usePageSearchBar(): PageSearchBarValue {
+ return useContext(PageSearchBarContext);
+}
+
+/**
+ * Marks the calling component as the page's search bar, so the nav bar hides its
+ * own field and hands focus requests over. Registered while mounted, or — when
+ * `visibilityRef` is given — only while that element is in the viewport. Returns
+ * whether the bar is currently registered; callers gate focus handling and their
+ * view-transition name on it.
+ */
+// eslint-disable-next-line react-refresh/only-export-components
+export function useRegisterPageSearchBar(visibilityRef?: RefObject): boolean {
+ const { registerPageSearchBar } = usePageSearchBar();
+ const [registered, setRegistered] = useState(true);
+ // Layout effect so the count is updated before other layout effects in the same commit.
+ useLayoutEffect(() => {
+ const el = visibilityRef?.current;
+ if (!el) {
+ return registerPageSearchBar();
+ }
+ let unregister: (() => void) | undefined;
+ const update = (visible: boolean) => {
+ if (visible && !unregister) {
+ unregister = registerPageSearchBar();
+ } else if (!visible && unregister) {
+ unregister();
+ unregister = undefined;
+ }
+ setRegistered(visible);
+ };
+ // Seed synchronously — the observer's first callback is async and the nav field would flash.
+ const rect = el.getBoundingClientRect();
+ update(rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth);
+ const observer = new IntersectionObserver(entries => update(entries[entries.length - 1].isIntersecting));
+ observer.observe(el);
+ return () => {
+ observer.disconnect();
+ unregister?.();
+ };
+ }, [registerPageSearchBar, visibilityRef]);
+ return registered;
+}
+
+export const PageSearchBarProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => {
+ // Ref for synchronous reads during a commit; the state mirror drives re-renders.
+ const pageSearchBarCount = useRef(0);
+ const [hasPageSearchBar, setHasPageSearchBar] = useState(false);
+ const registerPageSearchBar = useCallback(() => {
+ pageSearchBarCount.current++;
+ setHasPageSearchBar(true);
+ return () => {
+ pageSearchBarCount.current--;
+ setHasPageSearchBar(pageSearchBarCount.current > 0);
+ };
+ }, []);
+ const isPageSearchBarActive = useCallback(() => pageSearchBarCount.current > 0, []);
+ const value = useMemo(
+ () => ({ hasPageSearchBar, isPageSearchBarActive, registerPageSearchBar }),
+ [hasPageSearchBar, isPageSearchBarActive, registerPageSearchBar]
+ );
+ return {children} ;
+};
diff --git a/webui/src/context/search/search-context.tsx b/webui/src/context/search/search-context.tsx
new file mode 100644
index 000000000..3f969a342
--- /dev/null
+++ b/webui/src/context/search/search-context.tsx
@@ -0,0 +1,62 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { createContext, FunctionComponent, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { SearchFocusProvider } from './search-focus-context';
+import { PageSearchBarProvider } from './page-search-bar-context';
+
+export interface SearchContextValue {
+ query: string;
+ setQuery: (q: string) => void;
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const SearchContext = createContext({
+ query: '',
+ setQuery: () => {}
+});
+
+/**
+ * The draft query shared by the search fields. Only the fields themselves should
+ * read this (it changes per keystroke); pages read the applied filter from the
+ * URL via useSearch instead.
+ */
+// eslint-disable-next-line react-refresh/only-export-components
+export function useSearchQuery(): SearchContextValue {
+ return useContext(SearchContext);
+}
+
+// Mounts the whole search feature: the persisted query (so the search fields stay
+// in sync as the user navigates) plus the focus and page-bar contexts. Separate
+// contexts keep re-render scopes tight — the query changes per keystroke, the
+// others rarely. The `search` action lives in useSearch.
+export const SearchProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => {
+ const [query, setQuery] = useState('');
+ const [searchParams] = useSearchParams();
+ const urlQuery = searchParams.get('q') ?? '';
+
+ // Keep the fields in sync with URL query changes (back/forward, shared links, category tiles).
+ useEffect(() => {
+ setQuery(urlQuery);
+ }, [urlQuery]);
+
+ const value = useMemo(() => ({ query, setQuery }), [query]);
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/webui/src/context/search/search-focus-context.tsx b/webui/src/context/search/search-focus-context.tsx
new file mode 100644
index 000000000..5aa98de16
--- /dev/null
+++ b/webui/src/context/search/search-focus-context.tsx
@@ -0,0 +1,59 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { createContext, FunctionComponent, ReactNode, useContext, useMemo, useState } from 'react';
+import { GridStep } from '../../hooks/use-grid-cursor';
+import { Signal, useSignal } from '../../hooks/use-signal';
+
+/**
+ * Focus coordination between the search fields and the results grid. Signals are
+ * emitted by whichever component owns the intent; subscribers react via
+ * useSignalEffect and focus their own element, so no entry point needs a global
+ * DOM lookup.
+ */
+
+// Step the results cursor from the search field, or open the card under it.
+export type ResultsNavAction = GridStep | 'open';
+
+export interface SearchFocusValue {
+ // Ask the active search field (the page search bar when one is registered, nav bar otherwise) to focus.
+ searchFocusSignal: Signal;
+ // Drive the results grid's cursor while focus stays in the search field.
+ resultsNavigationSignal: Signal;
+ // Whether the search field has focus — the grid only shows its cursor then.
+ searchFocused: boolean;
+ setSearchFocused: (focused: boolean) => void;
+}
+
+const SearchFocusContext = createContext({
+ searchFocusSignal: { signal: 0, emit: () => {} },
+ resultsNavigationSignal: { signal: 0, emit: () => {} },
+ searchFocused: false,
+ setSearchFocused: () => {}
+});
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useSearchFocus(): SearchFocusValue {
+ return useContext(SearchFocusContext);
+}
+
+export const SearchFocusProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => {
+ const searchFocusSignal = useSignal();
+ const resultsNavigationSignal = useSignal();
+ const [searchFocused, setSearchFocused] = useState(false);
+ const value = useMemo(
+ () => ({ searchFocusSignal, resultsNavigationSignal, searchFocused, setSearchFocused }),
+ [searchFocusSignal, resultsNavigationSignal, searchFocused]
+ );
+ return {children} ;
+};
diff --git a/webui/src/default/default-app.tsx b/webui/src/default/default-app.tsx
index 8976c20e4..6db67b827 100644
--- a/webui/src/default/default-app.tsx
+++ b/webui/src/default/default-app.tsx
@@ -8,6 +8,13 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
+import '@fontsource-variable/geist/index.css';
+import '@fontsource-variable/geist-mono/index.css';
+import '@fontsource/roboto/300.css';
+import '@fontsource/roboto/400.css';
+import '@fontsource/roboto/500.css';
+import '@fontsource/roboto/700.css';
+import '../main.css';
import { createRoot } from 'react-dom/client';
import { useMemo } from 'react';
import { HelmetProvider } from 'react-helmet-async';
@@ -39,8 +46,7 @@ const service = new ExtensionRegistryService(`${location.protocol}//${serverHost
export const App = () => {
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const theme = useMemo(() => createDefaultTheme(prefersDarkMode ? 'dark' : 'light'), [prefersDarkMode]);
-
- const pageSettings = createPageSettings(prefersDarkMode, service.serverUrl);
+ const pageSettings = useMemo(() => createPageSettings(prefersDarkMode, service.serverUrl), [prefersDarkMode]);
return (
diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx
index e078ad297..aa742baef 100644
--- a/webui/src/default/menu-content.tsx
+++ b/webui/src/default/menu-content.tsx
@@ -8,30 +8,22 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import { FunctionComponent, PropsWithChildren, useContext, useRef } from 'react';
-import {
- Typography,
- MenuItem,
- Link,
- Button,
- IconButton,
- Accordion,
- AccordionSummary,
- Avatar,
- AccordionDetails
-} from '@mui/material';
-import { useLocation, Link as RouteLink } from 'react-router-dom';
-import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { FunctionComponent, PropsWithChildren, useContext, useRef, useState } from 'react';
+import { Typography, MenuItem, Link, Button, IconButton, Avatar, Menu } from '@mui/material';
+import { useLocation, useNavigate, Link as RouteLink } from 'react-router-dom';
+import MoreVertIcon from '@mui/icons-material/MoreVert';
import GitHubIcon from '@mui/icons-material/GitHub';
import MenuBookIcon from '@mui/icons-material/MenuBook';
-import ForumIcon from '@mui/icons-material/Forum';
import InfoIcon from '@mui/icons-material/Info';
import PublishIcon from '@mui/icons-material/Publish';
import AccountBoxIcon from '@mui/icons-material/AccountBox';
import { UserAvatar } from '../pages/user/avatar';
import { UserSettingsRoutes } from '../pages/user/user-settings-routes';
-import { styled, Theme } from '@mui/material/styles';
+import { alpha, styled, Theme } from '@mui/material/styles';
import { MainContext } from '../context';
+import { KbdKey } from '../components/kbd-key';
+import { useShortcut } from '../hooks/use-shortcut';
+import { focusOutline } from '../components/page-primitives';
import SettingsIcon from '@mui/icons-material/Settings';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import LogoutIcon from '@mui/icons-material/Logout';
@@ -39,6 +31,10 @@ import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-dashboard-r
import { LogoutForm } from '../pages/user/logout';
import { LoginComponent } from './login';
+// Shared destinations so a menu item's link, its keyboard shortcut, and its hint
+// can't drift apart. External docs open in the same tab, matching the link click.
+const DOCS_URL = 'https://github.com/eclipse/openvsx/wiki';
+
//-------------------- Mobile View --------------------//
// eslint-disable-next-line react-refresh/only-export-components
export const itemIcon = {
@@ -59,36 +55,44 @@ export const MenuItemText: FunctionComponent = ({ children })
};
export const MobileUserAvatar: FunctionComponent = () => {
- const context = useContext(MainContext);
- const user = context.user;
+ const { user } = useContext(MainContext);
const logoutFormRef = useRef(null);
+ const anchorRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const close = () => setOpen(false);
if (!user) {
return null;
}
return (
-
- } aria-controls='user-actions' id='user-avatar'>
+ <>
+ setOpen(true)} aria-haspopup='menu' aria-expanded={open}>
{user.loginName}
-
-
-
+
+
+
+
{user.loginName}
-
+
Settings
{user.role === 'admin' ? (
-
+
Admin Dashboard
@@ -103,8 +107,8 @@ export const MobileUserAvatar: FunctionComponent = () => {
-
-
+
+ >
);
};
@@ -131,7 +135,7 @@ export const MobileMenuContent: FunctionComponent = () => {
/>
))}
{loginProviders && !location.pathname.startsWith(UserSettingsRoutes.ROOT) && (
-
+
Publish Extension
@@ -144,20 +148,12 @@ export const MobileMenuContent: FunctionComponent = () => {
Source Code
-
+
Documentation
-
-
-
- Slack Workspace
-
-
@@ -172,17 +168,25 @@ export const MobileMenuContent: FunctionComponent = () => {
// eslint-disable-next-line react-refresh/only-export-components
export const headerItem = ({ theme }: { theme: Theme }) => ({
- margin: theme.spacing(2.5),
- color: theme.palette.text.primary,
+ margin: theme.spacing(0, 0.5),
+ padding: theme.spacing(1, 1.5),
+ // Inherited color so the items follow the nav's content color over page
+ // bands; opacity stands in for the secondary/primary text pair.
+ color: 'inherit',
+ opacity: 0.78,
textDecoration: 'none',
- fontSize: '1.1rem',
+ fontSize: '0.875rem',
fontFamily: theme.typography.fontFamily,
- fontWeight: theme.typography.fontWeightLight,
- letterSpacing: 1,
+ fontWeight: 500,
+ letterSpacing: 0,
+ borderRadius: `${theme.shape.borderRadius}px`,
+ transition: 'background 0.14s, opacity 0.14s',
'&:hover': {
- color: theme.palette.secondary.main,
+ opacity: 1,
+ backgroundColor: 'color-mix(in srgb, currentcolor 8%, transparent)',
textDecoration: 'none'
- }
+ },
+ ...focusOutline(theme)
});
// eslint-disable-next-line react-refresh/only-export-components
@@ -192,17 +196,46 @@ export const MenuRouteLink = styled(RouteLink)(headerItem);
export const DefaultMenuContent: FunctionComponent = () => {
const { user, loginProviders } = useContext(MainContext);
+ const navigate = useNavigate();
+
+ // Register each shortcut next to the control it drives, sharing the same
+ // destination so the hint, the click, and the keypress stay in sync.
+ useShortcut({ key: 'd', label: 'Documentation', order: 2, callback: () => window.location.assign(DOCS_URL) });
+ useShortcut({
+ key: 'p',
+ label: 'Publish',
+ order: 3,
+ callback: () => navigate(UserSettingsRoutes.EXTENSIONS),
+ enabled: !!loginProviders
+ });
+
return (
<>
- Documentation
-
- Slack Workspace
+
+ Documentation
+ d
About
{loginProviders && (
<>
-
+ ({
+ mx: 0.5,
+ px: 2.25,
+ py: 1,
+ fontWeight: 600,
+ fontSize: '0.8125rem',
+ borderRadius: `${theme.shape.borderRadius}px`,
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4375rem',
+ '&:hover': { backgroundColor: alpha(theme.palette.secondary.main, 0.08) }
+ })}>
Publish
+ p
{user ? (
@@ -210,15 +243,20 @@ export const DefaultMenuContent: FunctionComponent = () => {
{
+ // The default `action.active` gray disappears on tinted chromes.
if (href) {
return (
-
+
);
} else {
return (
-
+
);
diff --git a/webui/src/default/openvsx-registry-logo.tsx b/webui/src/default/openvsx-registry-logo.tsx
index ee5e5147a..24c027416 100644
--- a/webui/src/default/openvsx-registry-logo.tsx
+++ b/webui/src/default/openvsx-registry-logo.tsx
@@ -13,7 +13,7 @@ import { FunctionComponent } from 'react';
const OpenVSXLogo: FunctionComponent<{
width: string;
height: string;
- marginTop: string;
+ marginTop?: string;
prefersDarkMode: boolean;
}> = props => {
return (
@@ -22,7 +22,8 @@ const OpenVSXLogo: FunctionComponent<{
);
diff --git a/webui/src/default/page-settings.tsx b/webui/src/default/page-settings.tsx
index 82051bd10..c23131a12 100644
--- a/webui/src/default/page-settings.tsx
+++ b/webui/src/default/page-settings.tsx
@@ -8,83 +8,141 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
-import { FunctionComponent, ReactNode, useContext } from 'react';
+import { FunctionComponent, ReactNode } from 'react';
import { Helmet } from 'react-helmet-async';
-import { styled, Theme } from '@mui/material/styles';
-import { Link, Typography, Box } from '@mui/material';
+import { Typography, Box } from '@mui/material';
import { Link as RouteLink, Route, useParams } from 'react-router-dom';
import GitHubIcon from '@mui/icons-material/GitHub';
+import CallSplitIcon from '@mui/icons-material/CallSplit';
+import BugReportIcon from '@mui/icons-material/BugReport';
+import MenuBookIcon from '@mui/icons-material/MenuBook';
import { Extension, NamespaceDetails } from '../extension-registry-types';
import { PageSettings } from '../page-settings';
import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes';
import { DefaultMenuContent, MobileMenuContent } from './menu-content';
+import { OpenVsxMark } from '../components/openvsx-mark';
import OpenVSXLogo from './openvsx-registry-logo';
import About from './about';
import { createAbsoluteURL } from '../utils';
-import { MainContext } from '../context';
+
+const WIKI_URL = 'https://github.com/eclipse-openvsx/openvsx/wiki';
+const REPO_URL = 'https://github.com/eclipse-openvsx/openvsx';
+const ISSUES_URL = `${REPO_URL}/issues`;
export default function createPageSettings(prefersDarkMode: boolean, serverUrl: string): PageSettings {
const toolbarContent: FunctionComponent = () => (
-
-
+
+
);
- const link = ({ theme }: { theme: Theme }) => ({
- color: theme.palette.text.primary,
- textDecoration: 'none',
- '&:hover': {
- color: theme.palette.secondary.main,
- textDecoration: 'none'
- }
- });
-
- const StyledRouteLink = styled(RouteLink)(link);
+ const footer: PageSettings['elements']['footer'] = {
+ brand: {
+ logo: ,
+ name: 'Open VSX Registry',
+ description: 'An open-source, vendor-neutral registry for VS Code–compatible extensions.'
+ },
+ columns: [
+ {
+ heading: 'Resources',
+ links: [{ label: 'Documentation', href: WIKI_URL }]
+ },
+ {
+ heading: 'Community',
+ links: [
+ { label: 'GitHub', href: REPO_URL, external: true },
+ { label: 'About This Service', href: '/about' }
+ ]
+ }
+ ],
+ social: [{ title: 'GitHub', href: REPO_URL, icon: }],
+ copyright: 'Copyright © Eclipse Foundation, AISBL. All Rights Reserved.'
+ };
- const ServerVersion: FunctionComponent = () => {
- const { version } = useContext(MainContext);
- if (!version) {
- return Loading version...
;
+ const home: PageSettings['elements']['home'] = {
+ popularSearches: ['python', 'git', 'docker', 'prettier', 'eslint', 'rust', 'java'],
+ involvement: {
+ heading: 'Get Involved',
+ cards: [
+ {
+ icon: ,
+ title: 'Contribute',
+ description: 'Open VSX is fully open source. Help build the registry the ecosystem depends on.',
+ href: REPO_URL,
+ label: 'View on GitHub →'
+ },
+ {
+ icon: ,
+ title: 'Report an issue',
+ description: 'Found a bug or have a feature request? Open an issue and help improve the registry.',
+ href: ISSUES_URL,
+ label: 'Open an issue →'
+ },
+ {
+ icon: ,
+ title: 'Read the docs',
+ description: 'Learn how to publish, claim namespaces, and consume extensions via the API.',
+ href: WIKI_URL,
+ label: 'View documentation →'
+ }
+ ]
}
- return (
-
- Server Version: {version.version}
-
- );
};
- const footerContent: FunctionComponent<{ expanded: boolean }> = () => (
-
- ({
- ...link({ theme }),
- display: 'flex',
+ const searchHeader: FunctionComponent = () => (
+
+
-
- eclipse/openvsx
-
-
- About This Service
+ gap: '0.5rem',
+ px: '0.8125rem',
+ py: '0.375rem',
+ borderRadius: '999px',
+ bgcolor: 'accentSoft',
+ color: 'secondary.light',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ mb: 3
+ }}>
+
+ Open-source registry for VS Code–compatible editors
+
+
+ Find the right extension,
+
+ for any editor.
+
+
+ Browse community-published extensions.
+ Free, open, and vendor-neutral.
+
);
- const searchHeader: FunctionComponent = () => (
-
- Extensions for VS Code Compatible Editors
-
- );
-
const additionalRoutes: ReactNode = } />;
const headTags: FunctionComponent<{ title: string }> = props => {
@@ -128,12 +186,8 @@ export default function createPageSettings(prefersDarkMode: boolean, serverUrl:
toolbarContent,
defaultMenuContent: DefaultMenuContent,
mobileMenuContent: MobileMenuContent,
- footer: {
- content: footerContent,
- props: {
- footerHeight: 69 // Maximal height reached for small screens
- }
- },
+ footer,
+ home,
searchHeader,
additionalRoutes,
mainHeadTags,
diff --git a/webui/src/default/theme.tsx b/webui/src/default/theme.tsx
index 935d9003d..a04365e2c 100644
--- a/webui/src/default/theme.tsx
+++ b/webui/src/default/theme.tsx
@@ -10,8 +10,23 @@
import { CSSProperties } from 'react';
import { createTheme, Theme } from '@mui/material';
+import type {} from '@mui/x-data-grid/themeAugmentation';
+
+export const MONO_FONT = "'Geist Mono', monospace";
+export const NAVBAR_HEIGHT = '3.875rem';
+// Pixel twin for scroll math (rem values assume the 16px root font size).
+export const NAVBAR_HEIGHT_PX = parseFloat(NAVBAR_HEIGHT) * 16;
+
+// Shared look of floating surfaces (menus, popovers, dialogs). The nested selector
+// outranks MuiPaper's own rounded style, so no !important is needed.
+const floatingPaper = (theme: Theme) => ({
+ border: `1px solid ${theme.palette.divider}`,
+ boxShadow: 'var(--shadow-lg)',
+ backgroundColor: theme.palette.background.paper,
+ backgroundImage: 'none',
+ '&.MuiPaper-rounded': { borderRadius: theme.shape.borderRadiusCard }
+});
-// Shared type definitions for palette extensions
type Color = CSSProperties['color'];
interface StatusColors {
@@ -48,7 +63,6 @@ interface UnenforcedColors {
stripe: string;
}
-// Shared shape for custom palette properties
interface CustomPaletteColors {
neutral: NeutralColors;
textHint: Color;
@@ -64,6 +78,14 @@ interface CustomPaletteColors {
scanBackground: ScanBackgroundColors;
gray: GrayColors;
unenforced: UnenforcedColors;
+ // Surface tiers not covered by MUI's background.paper
+ surface2: string;
+ surface3: string;
+ border2: string;
+ accentSoft: string;
+ warningSoft: string;
+ warningAccent: string;
+ bg2: string;
}
declare module '@mui/material/styles/createPalette' {
@@ -74,52 +96,93 @@ declare module '@mui/material/styles/createPalette' {
textHint: Color;
}
}
+
+declare module '@mui/system/createTheme/shape' {
+ interface Shape {
+ borderRadiusCard: number;
+ }
+}
+
export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme {
+ const dark = themeType === 'dark';
return createTheme({
+ typography: {
+ fontFamily: "'Geist', 'Roboto', system-ui, -apple-system, sans-serif"
+ },
+ shape: {
+ borderRadius: 9,
+ borderRadiusCard: 14
+ },
+ mixins: {
+ toolbar: { minHeight: NAVBAR_HEIGHT }
+ },
palette: {
+ mode: themeType,
+ // Standard MUI palette — these replace the matching CSS vars
+ background: {
+ default: dark ? '#0c0c11' : '#ffffff',
+ paper: dark ? '#15151d' : '#ffffff'
+ },
+ text: {
+ primary: dark ? '#ededf2' : '#16161c',
+ secondary: dark ? '#b2b2bf' : '#54545f',
+ disabled: dark ? '#7a7a87' : '#8c8c98'
+ },
+ divider: dark ? '#262630' : '#e9e9ee',
primary: {
- main: themeType === 'dark' ? '#eeeeee' : '#444',
- dark: themeType === 'dark' ? '#f4f4f4' : '#565157'
+ main: dark ? '#ededf2' : '#16161c',
+ dark: dark ? '#f4f4f4' : '#0d0d11'
},
secondary: {
- main: themeType === 'dark' ? '#c160ef' : '#a60ee5',
- contrastText: '#edf5ea'
+ main: dark ? '#a855f7' : '#8b1fd6',
+ dark: dark ? '#9333ea' : '#7916bd',
+ light: dark ? '#c084fc' : '#8b1fd6', // accent-fg
+ contrastText: '#ffffff'
},
+ // Custom surface tiers
+ bg2: dark ? '#101016' : '#f7f7f9',
+ surface2: dark ? '#1a1a23' : '#fafafa',
+ surface3: dark ? '#20202b' : '#f2f2f5',
+ border2: dark ? '#1d1d26' : '#f0f0f3',
+ accentSoft: dark ? '#291a3d' : '#f4e9fd',
+ warningSoft: dark ? '#3a2c14' : '#fdf3e2',
+ warningAccent: dark ? '#fbbf24' : '#d97706',
+ // Legacy admin palette
neutral: {
- light: themeType === 'dark' ? '#000' : '#e6e6e6',
- dark: themeType === 'dark' ? '#151515' : '#fff'
+ light: dark ? '#000' : '#e6e6e6',
+ dark: dark ? '#151515' : '#fff'
},
textHint: 'rgba(0, 0, 0, 0.38)',
- checkboxUnchecked: themeType === 'dark' ? 'rgba(255, 255, 255, 0.23)' : 'rgba(0, 0, 0, 0.23)',
+ checkboxUnchecked: dark ? 'rgba(255, 255, 255, 0.23)' : 'rgba(0, 0, 0, 0.23)',
passed: {
- dark: themeType === 'dark' ? '#2e5c32' : '#4db052',
- light: themeType === 'dark' ? '#a5d6a7' : '#c8e6c9'
+ dark: dark ? '#2e5c32' : '#4db052',
+ light: dark ? '#a5d6a7' : '#c8e6c9'
},
quarantined: {
- dark: themeType === 'dark' ? '#8e5518' : '#e09030',
- light: themeType === 'dark' ? '#ffcc80' : '#ffe0b2'
+ dark: dark ? '#8e5518' : '#e09030',
+ light: dark ? '#ffcc80' : '#ffe0b2'
},
rejected: {
- dark: themeType === 'dark' ? '#7d2e2e' : '#d63c3c',
- light: themeType === 'dark' ? '#ef9a9a' : '#ffcdd2'
+ dark: dark ? '#7d2e2e' : '#d63c3c',
+ light: dark ? '#ef9a9a' : '#ffcdd2'
},
errorStatus: {
- dark: themeType === 'dark' ? '#5a5a5a' : '#8a8a8a',
- light: themeType === 'dark' ? '#b0b0b0' : '#e0e0e0'
+ dark: dark ? '#5a5a5a' : '#8a8a8a',
+ light: dark ? '#b0b0b0' : '#e0e0e0'
},
allowed: '#4caf50',
blocked: '#f44336',
review: '#e6a800',
selected: {
border: '#c160ef',
- background: themeType === 'dark' ? '#3d1b4d' : '#f3e5f9',
- backgroundHover: themeType === 'dark' ? '#4d2360' : '#e9d5f5',
- hover: themeType === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.04)'
+ background: dark ? '#3d1b4d' : '#f3e5f9',
+ backgroundHover: dark ? '#4d2360' : '#e9d5f5',
+ hover: dark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.04)'
},
scanBackground: {
- default: themeType === 'dark' ? '#1e1e1e' : '#f5f5f5',
- light: themeType === 'dark' ? '#2d2d2d' : '#f0f0f0',
- dark: themeType === 'dark' ? '#0a0a0a' : '#fafafa'
+ default: dark ? '#1e1e1e' : '#f5f5f5',
+ light: dark ? '#2d2d2d' : '#f0f0f0',
+ dark: dark ? '#0a0a0a' : '#fafafa'
},
gray: {
start: '#888888',
@@ -128,20 +191,131 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme {
gradient: 'linear-gradient(90deg, #888888 0%, #cccccc 50%, #888888 100%)'
},
unenforced: {
- stripe:
- themeType === 'dark'
- ? 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255, 255, 255, 0.12) 4px, rgba(255, 255, 255, 0.12) 8px)'
- : 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0, 0, 0, 0.12) 4px, rgba(0, 0, 0, 0.12) 8px)'
- },
- mode: themeType
+ stripe: dark
+ ? 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255, 255, 255, 0.12) 4px, rgba(255, 255, 255, 0.12) 8px)'
+ : 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0, 0, 0, 0.12) 4px, rgba(0, 0, 0, 0.12) 8px)'
+ }
},
breakpoints: {
- values: {
- xs: 340,
- sm: 550,
- md: 800,
- lg: 1040,
- xl: 1240
+ values: { xs: 0, sm: 550, md: 800, lg: 1040, xl: 1240 }
+ },
+ components: {
+ MuiAccordion: {
+ styleOverrides: {
+ root: {
+ border: 0,
+ boxShadow: 'none',
+ background: 'transparent',
+ '&:before': { display: 'none' }
+ }
+ }
+ },
+ MuiButton: {
+ styleOverrides: {
+ root: { textTransform: 'none' }
+ }
+ },
+ // MUI X derives the grid's borders from `divider` via lighten/darken, which
+ // almost erases our already-light opaque divider. Feed the grid's design
+ // tokens directly; `&.MuiDataGrid-root` outranks the injected
+ // `.MuiDataGridVariables-*` class that carries the derived defaults.
+ MuiDataGrid: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ '&.MuiDataGrid-root': {
+ '--DataGrid-t-color-border-base': theme.palette.divider,
+ '--DataGrid-t-header-background-base': theme.palette.bg2,
+ '--DataGrid-t-radius-base': `${theme.shape.borderRadiusCard}px`,
+ '--DataGrid-t-color-interactive-focus': theme.palette.secondary.main,
+ '--DataGrid-t-color-interactive-selected': theme.palette.secondary.main,
+ '--DataGrid-t-color-foreground-accent': theme.palette.secondary.light
+ }
+ }),
+ columnHeaderTitle: ({ theme }) => ({
+ fontSize: '0.8125rem',
+ fontWeight: 600,
+ color: theme.palette.text.secondary
+ })
+ }
+ },
+ MuiTableCell: {
+ styleOverrides: {
+ root: ({ theme }) => ({ borderBottomColor: theme.palette.divider })
+ }
+ },
+ MuiTabs: {
+ styleOverrides: {
+ indicator: ({ theme }) => ({
+ backgroundColor: theme.palette.secondary.main,
+ height: '2px'
+ })
+ }
+ },
+ MuiTab: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ fontSize: '0.875rem',
+ fontWeight: 600,
+ textTransform: 'none',
+ color: theme.palette.text.disabled,
+ minHeight: '3.25rem',
+ padding: '0.9375rem 1rem',
+ '&.Mui-selected': { color: theme.palette.text.primary }
+ })
+ }
+ },
+ // Menu and Select popups inherit the floating look from MuiPopover.
+ MuiMenu: {
+ styleOverrides: {
+ paper: { marginTop: '0.375rem' },
+ list: { padding: '0.375rem' }
+ }
+ },
+ MuiMenuItem: {
+ styleOverrides: {
+ root: ({ theme }) => ({
+ borderRadius: theme.shape.borderRadius,
+ fontSize: '0.875rem',
+ fontWeight: 500,
+ minHeight: '2.25rem'
+ })
+ }
+ },
+ MuiTypography: {
+ styleOverrides: {
+ button: {
+ textTransform: 'none',
+ fontWeight: 500,
+ letterSpacing: 0
+ },
+ overline: {
+ textTransform: 'none',
+ letterSpacing: 0,
+ lineHeight: 1.4
+ }
+ }
+ },
+ MuiPopover: {
+ styleOverrides: {
+ paper: ({ theme }) => floatingPaper(theme)
+ }
+ },
+ MuiDialog: {
+ styleOverrides: {
+ paper: ({ theme }) => floatingPaper(theme)
+ }
+ },
+ MuiDivider: {
+ styleOverrides: {
+ root: ({ theme }) => ({ borderColor: theme.palette.divider })
+ }
+ },
+ MuiOutlinedInput: {
+ styleOverrides: {
+ notchedOutline: ({ theme }) => ({
+ borderColor: theme.palette.divider
+ })
+ }
}
}
});
diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts
index d96f9a08a..8191333ab 100644
--- a/webui/src/extension-registry-service.ts
+++ b/webui/src/extension-registry-service.ts
@@ -9,9 +9,10 @@
********************************************************************************/
import {
+ CATEGORIES,
Extension,
- UserData,
ExtensionCategory,
+ UserData,
ExtensionReviewList,
PersonalAccessToken,
SearchResult,
@@ -157,7 +158,8 @@ export class ExtensionRegistryService {
if (filter.sortOrder) query.push({ key: 'sortOrder', value: filter.sortOrder });
}
const endpoint = createAbsoluteURL([this.serverUrl, 'api', '-', 'search'], query);
- return sendRequest({ abortController, endpoint });
+ // Non-retriable: retries are owned by the TanStack query that calls this.
+ return sendNonRetriableRequest({ abortController, endpoint });
}
async getExtensionDetail(
@@ -204,24 +206,9 @@ export class ExtensionRegistryService {
});
}
+ /** @deprecated Use the CATEGORIES constant from extension-registry-types instead. */
getCategories(): ExtensionCategory[] {
- return [
- 'Programming Languages',
- 'Snippets',
- 'Linters',
- 'Themes',
- 'Debuggers',
- 'Formatters',
- 'Keymaps',
- 'SCM Providers',
- 'Other',
- 'Extension Packs',
- 'Language Packs',
- 'Data Science',
- 'Machine Learning',
- 'Visualization',
- 'Notebooks'
- ];
+ return [...CATEGORIES];
}
async getExtensionReviews(
@@ -1486,7 +1473,7 @@ export class AdminServiceImpl implements AdminService {
export interface ExtensionFilter {
query: string;
- category: ExtensionCategory | '';
+ category: string;
size: number;
offset: number;
sortBy: SortBy;
diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts
index 85fd9975c..4e0243ee4 100644
--- a/webui/src/extension-registry-types.ts
+++ b/webui/src/extension-registry-types.ts
@@ -206,22 +206,26 @@ export interface PersonalAccessToken {
deleteTokenUrl: UrlString;
}
-export type ExtensionCategory =
- | 'Programming Languages'
- | 'Snippets'
- | 'Linters'
- | 'Themes'
- | 'Debuggers'
- | 'Formatters'
- | 'Keymaps'
- | 'SCM Providers'
- | 'Other'
- | 'Extension Packs'
- | 'Language Packs'
- | 'Data Science'
- | 'Machine Learning'
- | 'Visualization'
- | 'Notebooks';
+export const CATEGORIES = [
+ 'AI',
+ 'Programming Languages',
+ 'Snippets',
+ 'Linters',
+ 'Themes',
+ 'Debuggers',
+ 'Formatters',
+ 'Keymaps',
+ 'SCM Providers',
+ 'Other',
+ 'Extension Packs',
+ 'Language Packs',
+ 'Data Science',
+ 'Machine Learning',
+ 'Visualization',
+ 'Notebooks'
+] as const;
+
+export type ExtensionCategory = (typeof CATEGORIES)[number];
export interface CsrfTokenJson {
value: string;
diff --git a/webui/src/header-menu.tsx b/webui/src/header-menu.tsx
index ab4fa6746..6cc81c5a2 100644
--- a/webui/src/header-menu.tsx
+++ b/webui/src/header-menu.tsx
@@ -44,6 +44,8 @@ export const MobileHeaderMenu: FunctionComponent = props
{
setAnchorEl(event.currentTarget);
setOpen(!open);
diff --git a/webui/src/hooks/use-debounced-callback.ts b/webui/src/hooks/use-debounced-callback.ts
index 51bff5d83..5b55a3efd 100644
--- a/webui/src/hooks/use-debounced-callback.ts
+++ b/webui/src/hooks/use-debounced-callback.ts
@@ -11,26 +11,48 @@
* SPDX-License-Identifier: EPL-2.0
*****************************************************************************/
-import { useMemo, useRef } from 'react';
+import { useEffect, useMemo, useRef } from 'react';
const DEFAULT_DELAY_MS = 300;
+export interface DebouncedCallback void> {
+ (...args: Parameters): void;
+ // Drop any pending invocation.
+ cancel: () => void;
+}
+
/**
* Returns a stable function that delays invoking `callback` until `delay` ms
* have elapsed since the last call. Always invokes the most recent `callback`.
+ * The returned function also exposes `cancel` to drop a pending invocation.
*/
export function useDebouncedCallback void>(
callback: T,
delay: number = DEFAULT_DELAY_MS
-): (...args: Parameters) => void {
+): DebouncedCallback {
const callbackRef = useRef(callback);
callbackRef.current = callback;
- return useMemo(() => {
+ const debounced = useMemo(() => {
let timer: ReturnType | undefined;
- return (...args: Parameters) => {
+
+ const fn = ((...args: Parameters) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ timer = undefined;
+ callbackRef.current(...args);
+ }, delay);
+ }) as DebouncedCallback;
+
+ fn.cancel = () => {
clearTimeout(timer);
- timer = setTimeout(() => callbackRef.current(...args), delay);
+ timer = undefined;
};
+
+ return fn;
}, [delay]);
+
+ useEffect(() => debounced.cancel, [debounced]);
+
+ return debounced;
}
diff --git a/webui/src/hooks/use-extension-results-cursor.ts b/webui/src/hooks/use-extension-results-cursor.ts
new file mode 100644
index 000000000..fc7e2c3fe
--- /dev/null
+++ b/webui/src/hooks/use-extension-results-cursor.ts
@@ -0,0 +1,48 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { useCallback } from 'react';
+import { ResultsNavAction, useSearchFocus } from '../context/search/search-focus-context';
+import { GridCursor, useGridCursor } from './use-grid-cursor';
+import { useSignalEffect } from './use-signal-effect';
+
+/**
+ * Wires the generic grid cursor to the search experience: the cursor ring is
+ * visible while the search field has focus, the field's arrow keys drive the
+ * cursor through `resultsNavigationSignal` ('open' clicks the card under it),
+ * and ArrowUp from the first row hands focus back to the search field.
+ */
+export function useExtensionResultsCursor(itemCount: number): GridCursor {
+ const { searchFocusSignal, resultsNavigationSignal, searchFocused } = useSearchFocus();
+ const cursor = useGridCursor(itemCount, {
+ cursorVisible: searchFocused,
+ onExitTop: searchFocusSignal.emit
+ });
+
+ const { move, openActive } = cursor;
+ useSignalEffect(
+ resultsNavigationSignal,
+ useCallback(
+ (action: ResultsNavAction) => {
+ if (action === 'open') {
+ openActive();
+ } else {
+ move(action);
+ }
+ },
+ [move, openActive]
+ )
+ );
+
+ return cursor;
+}
diff --git a/webui/src/hooks/use-grid-cursor.ts b/webui/src/hooks/use-grid-cursor.ts
new file mode 100644
index 000000000..9af21548c
--- /dev/null
+++ b/webui/src/hooks/use-grid-cursor.ts
@@ -0,0 +1,208 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FocusEvent, KeyboardEvent, RefCallback, RefObject, useCallback, useRef, useState } from 'react';
+
+type GridDirection = 'up' | 'down' | 'left' | 'right';
+
+/** Linear cursor steps in reading order, for driving the cursor from outside the grid. */
+export type GridStep = 'next' | 'previous';
+
+const ARROW_KEY_DIRECTIONS: Record = {
+ ArrowUp: 'up',
+ ArrowDown: 'down',
+ ArrowLeft: 'left',
+ ArrowRight: 'right'
+};
+
+function getNextIndex(direction: GridDirection, current: number, cols: number, last: number): number {
+ switch (direction) {
+ case 'right':
+ return Math.min(current + 1, last);
+ case 'left':
+ return Math.max(current - 1, 0);
+ case 'down':
+ return Math.min(current + cols, last);
+ case 'up':
+ return current < cols ? current : current - cols;
+ }
+}
+
+export interface GridCursorOptions {
+ /** Show the cursor ring on the active item (sets `data-cursor-visible` on the container). */
+ cursorVisible?: boolean;
+ /** Called when ArrowUp is pressed on the first row (e.g. to hand focus back to a search field). */
+ onExitTop?: () => void;
+}
+
+export interface GridContainerProps {
+ ref: RefObject;
+ onKeyDown: (event: KeyboardEvent) => void;
+ onFocus: (event: FocusEvent) => void;
+ 'data-cursor-visible'?: string;
+}
+
+export interface GridItemProps {
+ ref: RefCallback;
+ tabIndex: number;
+ 'data-active'?: string;
+}
+
+export interface GridCursor {
+ /** Spread onto the CSS grid container. */
+ containerProps: GridContainerProps;
+ /** Spread onto the focusable element of the item at `index`. */
+ itemProps: (index: number) => GridItemProps;
+ /** Step the cursor one item in reading order without moving focus (e.g. while a search field keeps focus). */
+ move: (step: GridStep) => void;
+ /** Click the item under the cursor. */
+ openActive: () => void;
+ /** Put the cursor back on the first item (e.g. when the list is replaced). */
+ reset: () => void;
+}
+
+/**
+ * Cursor for a CSS grid of focusable items.
+ *
+ * The active item is both the visible cursor (`data-active`, styled while the
+ * container has `data-cursor-visible`) and the grid's only tab stop (roving
+ * tabindex). It moves two ways: arrow keys inside the grid move real focus
+ * across both axes, while `move()` steps the cursor one item at a time in
+ * reading order with focus staying wherever it is. The column count is read
+ * from the container's computed grid tracks, so it adapts to viewport width.
+ */
+export function useGridCursor(itemCount: number, options: GridCursorOptions = {}): GridCursor {
+ const { cursorVisible = false, onExitTop } = options;
+ const containerRef = useRef(null);
+ const itemRefs = useRef(new Map());
+ const refCallbacks = useRef(new Map>());
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ // Clamp instead of resetting state so the cursor survives the list shrinking.
+ const active = itemCount === 0 ? 0 : Math.min(activeIndex, itemCount - 1);
+ const activeRef = useRef(active);
+ activeRef.current = active;
+
+ const getColumnCount = useCallback((): number => {
+ const container = containerRef.current;
+ if (!container) return 1;
+ const tracks = getComputedStyle(container).gridTemplateColumns;
+ return tracks === 'none' ? 1 : tracks.split(' ').length;
+ }, []);
+
+ const moveTo = useCallback((index: number, opts: { focus: boolean }): void => {
+ setActiveIndex(index);
+ const item = itemRefs.current.get(index);
+ if (!item) return;
+ if (opts.focus) {
+ item.focus();
+ } else {
+ item.scrollIntoView({ block: 'nearest' });
+ }
+ }, []);
+
+ const move = useCallback(
+ (step: GridStep): void => {
+ if (itemCount === 0) return;
+ const delta = step === 'next' ? 1 : -1;
+ const next = Math.min(Math.max(activeRef.current + delta, 0), itemCount - 1);
+ if (next !== activeRef.current) {
+ moveTo(next, { focus: false });
+ }
+ },
+ [itemCount, moveTo]
+ );
+
+ const openActive = useCallback((): void => {
+ itemRefs.current.get(activeRef.current)?.click();
+ }, []);
+
+ const reset = useCallback((): void => setActiveIndex(0), []);
+
+ const onKeyDown = useCallback(
+ (event: KeyboardEvent): void => {
+ if (itemCount === 0) return;
+ const current = activeRef.current;
+ const last = itemCount - 1;
+ const cols = getColumnCount();
+
+ if (event.key === 'ArrowUp' && current < cols && onExitTop) {
+ event.preventDefault();
+ onExitTop();
+ return;
+ }
+ let next: number;
+ if (event.key === 'Home') {
+ next = 0;
+ } else if (event.key === 'End') {
+ next = last;
+ } else {
+ const direction = ARROW_KEY_DIRECTIONS[event.key];
+ if (!direction) return;
+ next = getNextIndex(direction, current, cols, last);
+ }
+ if (next !== current) {
+ event.preventDefault();
+ moveTo(next, { focus: true });
+ }
+ },
+ [itemCount, getColumnCount, moveTo, onExitTop]
+ );
+
+ // Keep the cursor on whichever item receives real focus (Tab, click).
+ const onFocus = useCallback((event: FocusEvent): void => {
+ for (const [index, item] of itemRefs.current) {
+ if (item === event.target) {
+ setActiveIndex(index);
+ return;
+ }
+ }
+ }, []);
+
+ const itemProps = useCallback(
+ (index: number): GridItemProps => {
+ let ref = refCallbacks.current.get(index);
+ if (!ref) {
+ // Cached per index so a memoized item only re-renders when its
+ // tabIndex / data-active actually change.
+ ref = element => {
+ if (element) {
+ itemRefs.current.set(index, element);
+ } else {
+ itemRefs.current.delete(index);
+ }
+ };
+ refCallbacks.current.set(index, ref);
+ }
+ return {
+ ref,
+ tabIndex: index === active ? 0 : -1,
+ 'data-active': index === active ? '' : undefined
+ };
+ },
+ [active]
+ );
+
+ return {
+ containerProps: {
+ ref: containerRef,
+ onKeyDown,
+ onFocus,
+ 'data-cursor-visible': cursorVisible ? '' : undefined
+ },
+ itemProps,
+ move,
+ openActive,
+ reset
+ };
+}
diff --git a/webui/src/hooks/use-infinite-search.ts b/webui/src/hooks/use-infinite-search.ts
new file mode 100644
index 000000000..98bf61464
--- /dev/null
+++ b/webui/src/hooks/use-infinite-search.ts
@@ -0,0 +1,61 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { useContext } from 'react';
+import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query';
+import { MainContext } from '../context';
+import { isError, SearchResult } from '../extension-registry-types';
+import { ExtensionFilter } from '../extension-registry-service';
+import { controllerFromSignal } from '../query-client';
+
+/** The filter fields that identify a result set; `offset` is driven by the pages. */
+export type SearchQuery = Omit;
+
+export const searchKeys = {
+ list: (query: SearchQuery) => ['search', query] as const
+};
+
+/**
+ * Loads search results one offset-paged page at a time. The pages stay in the
+ * query cache keyed by the filter, so returning to a scrolled search restores
+ * every loaded page (and thus its scroll position) instead of refetching from
+ * the first page. `keepPreviousData` keeps the current results on screen while
+ * a changed filter loads.
+ */
+export const useInfiniteSearch = (filter: ExtensionFilter) => {
+ const { service } = useContext(MainContext);
+ const { query, category, size, sortBy, sortOrder } = filter;
+ return useInfiniteQuery({
+ queryKey: searchKeys.list({ query, category, size, sortBy, sortOrder }),
+ queryFn: async ({ pageParam, signal }): Promise => {
+ const result = await service.search(controllerFromSignal(signal), {
+ query,
+ category,
+ size,
+ sortBy,
+ sortOrder,
+ offset: pageParam
+ });
+ if (isError(result)) {
+ throw result;
+ }
+ return result as SearchResult;
+ },
+ initialPageParam: 0,
+ getNextPageParam: (lastPage, allPages) => {
+ const loaded = allPages.reduce((sum, page) => sum + page.extensions.length, 0);
+ return lastPage.extensions.length > 0 && loaded < lastPage.totalSize ? loaded : undefined;
+ },
+ placeholderData: keepPreviousData
+ });
+};
diff --git a/webui/src/hooks/use-search.ts b/webui/src/hooks/use-search.ts
new file mode 100644
index 000000000..2880360d8
--- /dev/null
+++ b/webui/src/hooks/use-search.ts
@@ -0,0 +1,76 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { useCallback, useMemo } from 'react';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
+import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes';
+import { ExtensionCategory, SortBy, SortOrder } from '../extension-registry-types';
+
+export interface SearchFilter {
+ query: string;
+ category: (ExtensionCategory | '') & string;
+ sortBy: SortBy;
+ sortOrder: SortOrder;
+}
+
+/** Debounce for the extension search field — short enough to feel instant while still batching fast typing. */
+export const SEARCH_DEBOUNCE_MS = 150;
+
+// Write only non-default values so shared links stay clean.
+function filterToParams({ query, category, sortBy, sortOrder }: SearchFilter): Record {
+ const params: Record = {};
+ if (query) params.q = query;
+ if (category) params.category = category;
+ if (sortBy !== 'relevance') params.sortBy = sortBy;
+ if (sortOrder !== 'desc') params.sortOrder = sortOrder;
+ return params;
+}
+
+/** The applied search filter (from the URL) and the `search` navigation action. */
+export function useSearch() {
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ // Memoized so `filter` and `search` keep their identity between renders of the same URL.
+ const filter: SearchFilter = useMemo(
+ () => ({
+ query: searchParams.get('q') ?? '',
+ category: (searchParams.get('category') as ExtensionCategory) ?? '',
+ sortBy: (searchParams.get('sortBy') as SortBy) ?? 'relevance',
+ sortOrder: (searchParams.get('sortOrder') as SortOrder) ?? 'desc'
+ }),
+ [searchParams]
+ );
+
+ // On the search page: patch URL params in place (replace, no new history entry).
+ // From anywhere else: push a navigation to the search route.
+ const search = useCallback(
+ (patch: Partial) => {
+ const next = { ...filter, ...patch };
+ // Trim here so every entry point (nav field, hero, chips) searches the same way.
+ next.query = next.query.trim();
+ const params = filterToParams(next);
+
+ if (pathname === ExtensionListRoutes.SEARCH) {
+ setSearchParams(params, { replace: true });
+ return;
+ }
+
+ navigate({ pathname: ExtensionListRoutes.SEARCH, search: new URLSearchParams(params).toString() });
+ },
+ [navigate, pathname, filter, setSearchParams]
+ );
+
+ return { search, filter };
+}
diff --git a/webui/src/hooks/use-shortcut.ts b/webui/src/hooks/use-shortcut.ts
new file mode 100644
index 000000000..c8dfb59a6
--- /dev/null
+++ b/webui/src/hooks/use-shortcut.ts
@@ -0,0 +1,42 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { useEffect, useRef } from 'react';
+import { useKeyboardShortcuts } from '../context/keyboard-shortcuts-context';
+
+export interface UseShortcutOptions {
+ key: string;
+ label: string;
+ callback: () => void;
+ order?: number;
+ enabled?: boolean;
+}
+
+/**
+ * Register a keyboard shortcut for the lifetime of the calling component.
+ * The shortcut is automatically unregistered when the component unmounts.
+ * Callback is kept fresh via a ref — no re-registration on re-render.
+ */
+export function useShortcut({ key, label, callback, order = 99, enabled = true }: UseShortcutOptions): void {
+ const { register, unregister } = useKeyboardShortcuts();
+
+ // Keep callback fresh without forcing re-registration on every render
+ const callbackRef = useRef(callback);
+ callbackRef.current = callback;
+
+ useEffect(() => {
+ if (!enabled) return;
+ register({ key, label, order, callback: () => callbackRef.current() });
+ return () => unregister(key);
+ }, [key, label, order, enabled]);
+}
diff --git a/webui/src/hooks/use-signal-effect.ts b/webui/src/hooks/use-signal-effect.ts
new file mode 100644
index 000000000..3a3ad5f4e
--- /dev/null
+++ b/webui/src/hooks/use-signal-effect.ts
@@ -0,0 +1,38 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { useLayoutEffect, useRef } from 'react';
+import { Signal } from './use-signal';
+
+/**
+ * Runs `effect` whenever `signal` is emitted, skipping the initial mount. Used
+ * to coordinate imperative actions (e.g. moving focus) across components — a
+ * component emits the signal to "send" a request and subscribers react without
+ * any global DOM lookups. If the signal carries a payload, the latest emitted
+ * value is passed to `effect`.
+ *
+ * Uses a layout effect so focus moves synchronously after a `flushSync`
+ * navigation (keeping the mobile keyboard open during the hero → nav morph).
+ * Wrap `effect` in `useCallback` so it only re-subscribes when its deps change.
+ */
+export function useSignalEffect(signal: Signal, effect: (payload: T) => void): void {
+ const last = useRef(signal.signal);
+ useLayoutEffect(() => {
+ if (signal.signal === last.current) {
+ return;
+ }
+ last.current = signal.signal;
+ // Only reached after an emit, so a carried payload is always set.
+ effect(signal.payload as T);
+ }, [signal, effect]);
+}
diff --git a/webui/src/hooks/use-signal.ts b/webui/src/hooks/use-signal.ts
new file mode 100644
index 000000000..24502bf55
--- /dev/null
+++ b/webui/src/hooks/use-signal.ts
@@ -0,0 +1,36 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { useCallback, useMemo, useState } from 'react';
+
+export interface Signal {
+ /** Monotonically increasing counter; bumped on every emit. */
+ signal: number;
+ /** Value passed to the latest emit, if the signal carries one. */
+ payload?: T;
+ /** Broadcast the signal to subscribers. */
+ emit: (payload: T) => void;
+}
+
+/**
+ * Creates a one-off broadcast channel backed by a monotonically increasing
+ * counter. A component holding the signal calls `emit()` to fire it, and any
+ * number of subscribers react through `useSignalEffect` — coordinating
+ * imperative actions (e.g. moving focus) across components without global DOM
+ * lookups. Share the returned value through context to reach other components.
+ */
+export function useSignal(): Signal {
+ const [state, setState] = useState<{ signal: number; payload?: T }>({ signal: 0 });
+ const emit = useCallback((payload: T) => setState(s => ({ signal: s.signal + 1, payload })), []);
+ return useMemo(() => ({ signal: state.signal, payload: state.payload, emit }), [state, emit]);
+}
diff --git a/webui/src/index.ts b/webui/src/index.ts
index 1b754e9e3..ea90f90c1 100644
--- a/webui/src/index.ts
+++ b/webui/src/index.ts
@@ -13,6 +13,42 @@ export * from './page-settings';
export * from './extension-registry-service';
export * from './extension-registry-types';
export * from './pages/extension-detail/extension-detail';
-export * from './pages/extension-list/extension-list';
+export * from './components/extension-list';
export * from './pages/namespace-detail/namespace-detail';
export * from './pages/user/user-settings';
+
+export * from './components/kbd-key';
+export * from './components/openvsx-mark';
+
+// Building blocks of the built-in home page, for composing custom home pages.
+export { HeroSearch, type HeroSearchProps } from './pages/home/hero-search';
+export { BrowseCategories, type BrowseCategoriesProps } from './pages/home/browse-categories';
+export { CuratedSections, type CuratedSectionsProps } from './pages/home/curated-sections';
+export { GetInvolved, type GetInvolvedProps } from './pages/home/get-involved';
+export {
+ useHomeCategories,
+ useCuratedRows,
+ type CuratedRow,
+ DEFAULT_CURATED_SECTIONS
+} from './pages/home/use-home-data';
+export { ExtensionCard, type ExtensionCardProps } from './components/extension-card';
+export * from './components/page-primitives';
+// Leaf hook modules keep their helpers private, so `export *` exposes only the
+// public hook plus its types (e.g. useSearch + SearchFilter).
+export * from './hooks/use-search';
+export { useRegisterPageSearchBar } from './context/search/page-search-bar-context';
+
+// Keyboard shortcuts: register shortcuts from custom pages/components. The hook
+// only takes effect below a KeyboardShortcutsProvider — the built-in AppLayout
+// mounts one, so custom layouts need to mount their own.
+export { useShortcut, type UseShortcutOptions } from './hooks/use-shortcut';
+export {
+ KeyboardShortcutsProvider,
+ useKeyboardShortcuts,
+ type ShortcutInfo
+} from './context/keyboard-shortcuts-context';
+
+// Signal: lightweight cross-component coordination primitive. Create one with
+// useSignal, subscribe with useSignalEffect.
+export * from './hooks/use-signal';
+export * from './hooks/use-signal-effect';
diff --git a/webui/src/layout/app-footer.tsx b/webui/src/layout/app-footer.tsx
new file mode 100644
index 000000000..d553a81d9
--- /dev/null
+++ b/webui/src/layout/app-footer.tsx
@@ -0,0 +1,222 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, useContext, useState } from 'react';
+import { Box, Typography } from '@mui/material';
+import { styled } from '@mui/material/styles';
+import type { Theme } from '@mui/material/styles';
+import { Link as RouteLink } from 'react-router-dom';
+import { KbdKey } from '../components/kbd-key';
+import { useShortcut } from '../hooks/use-shortcut';
+import { Section, Eyebrow, accentHover, focusOutline } from '../components/page-primitives';
+import { MONO_FONT } from '../default/theme';
+import { CustomFooterSettings, StructuredFooterSettings } from '../page-settings';
+import { MainContext } from '../context';
+
+const footerLinkStyles = ({ theme }: { theme: Theme }) => ({
+ fontSize: '0.8125rem',
+ color: theme.palette.text.secondary,
+ textDecoration: 'none',
+ display: 'block',
+ '&:hover': { color: theme.palette.secondary.light },
+ ...focusOutline(theme)
+});
+
+const FooterLink = styled('a')(footerLinkStyles);
+const FooterRouteLink = styled(RouteLink)(footerLinkStyles);
+
+const SocialIconButton = styled('a')(({ theme }) => ({
+ width: 34,
+ height: 34,
+ borderRadius: theme.shape.borderRadius,
+ border: `1px solid ${theme.palette.divider}`,
+ backgroundColor: theme.palette.background.paper,
+ color: theme.palette.text.secondary,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ textDecoration: 'none',
+ transition: 'border-color 0.14s, color 0.14s',
+ ...accentHover(theme),
+ ...focusOutline(theme)
+}));
+
+const ShortcutsButton = styled('button')(({ theme }) => ({
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ padding: 0,
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ fontSize: '0.75rem',
+ color: theme.palette.text.disabled,
+ '&:hover': { color: theme.palette.secondary.light },
+ ...focusOutline(theme)
+}));
+
+/** Legacy footer chrome: fixed to the viewport bottom as on pre-redesign layouts, hover toggles `expanded`. */
+const LegacyFooterRoot = styled('footer')(({ theme }) => ({
+ position: 'fixed',
+ bottom: 0,
+ zIndex: 50,
+ width: '100%',
+ padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
+ backgroundColor: theme.palette.background.paper,
+ boxShadow: '0px -2px 6px 0px rgba(0, 0, 0, 0.5)',
+ backgroundImage:
+ theme.palette.mode === 'dark'
+ ? 'linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08))'
+ : undefined
+}));
+
+const LegacyFooter: FunctionComponent<{ footer: CustomFooterSettings }> = ({ footer }) => {
+ const Content = footer.content;
+ const [expanded, setExpanded] = useState(false);
+ return (
+ setExpanded(true)} onMouseLeave={() => setExpanded(false)}>
+
+
+ );
+};
+
+export interface AppFooterProps {
+ onOpenShortcuts: () => void;
+}
+
+export const AppFooter: FunctionComponent = ({ onOpenShortcuts }) => {
+ const { pageSettings, version } = useContext(MainContext);
+ const footer = pageSettings.elements.footer;
+
+ // The shortcuts button (and its ? hint) only renders in the structured footer,
+ // so register the shortcut here and disable it for custom/legacy footers.
+ const isStructuredFooter = typeof footer !== 'function' && !(footer && 'content' in footer);
+ useShortcut({
+ key: '?',
+ label: 'Show keyboard shortcuts',
+ order: 0,
+ callback: onOpenShortcuts,
+ enabled: isStructuredFooter
+ });
+
+ if (typeof footer === 'function') {
+ const CustomFooter = footer;
+ return ;
+ }
+ if (footer && 'content' in footer) {
+ return ;
+ }
+
+ return ;
+};
+
+interface StructuredFooterProps {
+ footer?: StructuredFooterSettings;
+ version: string | null;
+ onOpenShortcuts: () => void;
+}
+
+const StructuredFooter: FunctionComponent = ({ footer, version, onOpenShortcuts }) => (
+
+ {footer && (footer.brand || footer.columns) && (
+
+ {footer.brand && (
+
+
+ {footer.brand.logo}
+ {footer.brand.name}
+
+ {footer.brand.description && (
+
+ {footer.brand.description}
+
+ )}
+ {footer.social && footer.social.length > 0 && (
+
+ {footer.social.map(s => (
+
+ {s.icon}
+
+ ))}
+
+ )}
+
+ )}
+ {footer.columns?.map((column, ci) => (
+
+ {column.heading}
+
+ {column.links.map((l, li) =>
+ !l.external && l.href.startsWith('/') && !l.href.startsWith('//') ? (
+
+ {l.label}
+
+ ) : (
+
+ {l.label}
+
+ )
+ )}
+
+
+ ))}
+
+ )}
+
+ {footer?.copyright}
+
+ {footer?.extra}
+
+ Keyboard shortcuts
+ ?
+
+ {version && (
+
+ {version}
+
+ )}
+
+
+
+);
diff --git a/webui/src/layout/app-layout.tsx b/webui/src/layout/app-layout.tsx
new file mode 100644
index 000000000..1c2eb70d5
--- /dev/null
+++ b/webui/src/layout/app-layout.tsx
@@ -0,0 +1,153 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, lazy, Suspense, useContext, useEffect, useState } from 'react';
+import { Routes, Route, useNavigate } from 'react-router-dom';
+import { Box } from '@mui/material';
+import { styled } from '@mui/material/styles';
+import { Banner } from '../components/banner';
+import { ShortcutsModal } from '../components/shortcuts-modal';
+import { MainContext } from '../context';
+import { SearchProvider } from '../context/search/search-context';
+import { KeyboardShortcutsProvider } from '../context/keyboard-shortcuts-context';
+import { ExtensionTintProvider } from '../context/extension-tint-context';
+import { useShortcut } from '../hooks/use-shortcut';
+import { getCookieValueByKey, setCookie } from '../utils';
+import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes';
+import { UserSettingsRoutes } from '../pages/user/user-settings-routes';
+import { NamespaceDetailRoutes } from '../pages/namespace-detail/namespace-detail-routes';
+import { ExtensionDetailRoutes } from '../pages/extension-detail/extension-detail-routes';
+import { ExtensionDetail } from '../pages/extension-detail/extension-detail';
+import { HomePage } from '../pages/home/home-page';
+import { SearchPage } from '../pages/search/search-page';
+import { NamespaceDetail } from '../pages/namespace-detail/namespace-detail';
+import { NotFound } from '../not-found';
+import { NAVBAR_HEIGHT } from '../default/theme';
+import { AppNavbar } from './app-navbar';
+import { AppFooter } from './app-footer';
+import { ScrollToTop } from './scroll-to-top';
+
+const UserSettings = lazy(() => import('../pages/user/user-settings').then(m => ({ default: m.UserSettings })));
+
+const Wrapper = styled(Box)({
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative',
+ minHeight: '100vh'
+});
+
+const AppLayoutContent: FunctionComponent = props => {
+ const { pageSettings } = useContext(MainContext);
+ const { additionalRoutes: AdditionalRoutes, banner: BannerComponent, footer } = pageSettings.elements;
+ // The legacy footer is fixed to the viewport bottom; pad the content to stay clear of it.
+ const legacyFooterHeight = typeof footer === 'object' && 'content' in footer ? footer.props?.footerHeight : 0;
+
+ const navigate = useNavigate();
+ const [isBannerOpen, setIsBannerOpen] = useState(false);
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
+
+ useEffect(() => {
+ const banner = pageSettings.elements.banner;
+ if (!banner) return;
+ if (banner.cookie && getCookieValueByKey(banner.cookie.key) === banner.cookie.value) return;
+ // Start collapsed on load, then slide open a beat later for a gentle entrance.
+ const timer = setTimeout(() => setIsBannerOpen(true), 600);
+ return () => clearTimeout(timer);
+ }, []);
+
+ // Global navigation shortcuts with no on-screen affordance. Shortcuts tied to a
+ // visible control (?, d, p) are registered where that control renders, so a
+ // customized footer/menu brings its own instead of inheriting ours.
+ useShortcut({
+ key: 'h',
+ label: 'Go to home',
+ order: 4,
+ callback: () => navigate('/')
+ });
+ useShortcut({
+ key: 's',
+ label: 'Go to search',
+ order: 5,
+ callback: () => navigate('/search')
+ });
+
+ const onDismissBannerButtonClick = () => {
+ const onClose = pageSettings.elements.banner?.props?.onClose;
+ if (onClose) onClose();
+ const cookie = pageSettings.elements.banner?.cookie;
+ if (cookie) setCookie(cookie);
+ setIsBannerOpen(false);
+ };
+
+ return (
+
+
+ {BannerComponent ? (
+
+
+
+ ) : null}
+
+ {/* Mobile: fill the viewport so the (screen-tall) footer stays below the fold.
+ Desktop: flexGrow alone sticks the footer to the viewport bottom. */}
+
+
+
+ } />
+ } />
+ }
+ />
+ }
+ />
+ } />
+ } />
+ } />
+ {AdditionalRoutes ?? null}
+ } />
+
+
+
+ setShortcutsOpen(true)} />
+ setShortcutsOpen(false)} />
+
+ );
+};
+
+export const AppLayout: FunctionComponent = props => (
+
+
+
+
+
+
+
+);
+
+export interface AppLayoutProps {
+ userLoading: boolean;
+}
diff --git a/webui/src/layout/app-navbar.tsx b/webui/src/layout/app-navbar.tsx
new file mode 100644
index 000000000..7a774453b
--- /dev/null
+++ b/webui/src/layout/app-navbar.tsx
@@ -0,0 +1,250 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, useContext, useEffect, useRef, useState } from 'react';
+import { AppBar, Box, Toolbar } from '@mui/material';
+import { alpha, styled, useTheme } from '@mui/material/styles';
+import { Link as RouteLink } from 'react-router-dom';
+import { HeaderMenu } from '../header-menu';
+import { MainContext } from '../context';
+import { usePageSearchBar } from '../context/search/page-search-bar-context';
+import { useExtensionTint } from '../context/extension-tint-context';
+import { OpenVsxMark } from '../components/openvsx-mark';
+import { NavSearchField } from './nav-search-field';
+import { NAVBAR_HEIGHT_PX } from '../default/theme';
+
+const ToolbarItem = styled(Box)({
+ display: 'flex',
+ alignItems: 'center'
+});
+
+// On-color logo for page bands: brand purple can't be trusted against an
+// arbitrary band, so the marks take the inherited content color.
+const LOGO_ON_BAND_SX = {
+ '& svg path': { fill: 'currentColor' },
+ '& svg path:first-of-type': { fillOpacity: 0.72 }
+};
+
+// Fade stops sampled from smootherstep — a plain linear fade ends with an
+// abrupt slope change the eye reads as a line (Mach banding).
+const FADE_EASE = [1, 0.984, 0.897, 0.725, 0.5, 0.275, 0.103, 0.016, 0];
+
+// Progressive blur: each layer anchors to the top and fades out at a staggered
+// depth, so a row's blur compounds from the layers still active there (~6px at
+// the toolbar, ~0 at the fan's bottom). Saturation rides in the same filters —
+// every extra backdrop-filter layer re-snapshots the backdrop each frame.
+const BLUR_LAYERS = [
+ { blur: '5.2px', saturate: 1.35, fadeStart: 0 },
+ { blur: '2.75px', saturate: 1.25, fadeStart: 30 },
+ { blur: '1.2px', saturate: 1.2, fadeStart: 60 }
+].map(({ blur, saturate, fadeStart }) => ({
+ filter: `blur(${blur}) saturate(${saturate})`,
+ mask: `linear-gradient(to bottom, ${FADE_EASE.map(
+ (k, j) => `rgba(0, 0, 0, ${k}) ${fadeStart + (40 * j) / (FADE_EASE.length - 1)}%`
+ ).join(', ')})`
+}));
+
+// Nav-side view of the page's tint region: `navTint` is the color the nav
+// currently wears — the region's color until its depth scrolls past the nav
+// midpoint, then null to return to theme colors. `washColor` lags one step so
+// the wash gradient (not transitionable) can still fade out in the last color.
+const useNavTint = (): { navTint: string | null; washColor: string | null } => {
+ const tint = useExtensionTint();
+ const [pastTint, setPastTint] = useState(false);
+ useEffect(() => {
+ if (!tint) {
+ setPastTint(false);
+ return;
+ }
+ const onScroll = () => setPastTint(window.scrollY > tint.depth - NAVBAR_HEIGHT_PX / 2);
+ onScroll();
+ window.addEventListener('scroll', onScroll, { passive: true });
+ return () => window.removeEventListener('scroll', onScroll);
+ }, [tint]);
+ const navTint = tint && !pastTint ? tint.color : null;
+ const lastTint = useRef(navTint);
+ useEffect(() => {
+ if (navTint) lastTint.current = navTint;
+ }, [navTint]);
+ return { navTint, washColor: navTint ?? lastTint.current };
+};
+
+export const AppNavbar: FunctionComponent = () => {
+ const { pageSettings } = useContext(MainContext);
+ const { toolbarContent: ToolbarContent } = pageSettings.elements;
+ const { hasPageSearchBar } = usePageSearchBar();
+ const theme = useTheme();
+ const baseAlpha = theme.palette.mode === 'dark' ? 0.74 : 0.78;
+ const navbg = alpha(theme.palette.background.default, baseAlpha);
+ // Lighter than the solid glass — the blur fan already provides legibility.
+ const tintAlpha = theme.palette.mode === 'dark' ? 0.3 : 0.35;
+ const [scrolled, setScrolled] = useState(false);
+
+ useEffect(() => {
+ const onScroll = () => setScrolled(window.scrollY > 20);
+ window.addEventListener('scroll', onScroll, { passive: true });
+ return () => window.removeEventListener('scroll', onScroll);
+ }, []);
+
+ const showSolid = !scrolled;
+ const showFan = scrolled;
+
+ const { navTint, washColor } = useNavTint();
+
+ const fanBottom = { xs: '-48px', sm: '-80px' };
+ const tintBottom = { xs: '-48px', sm: '-150px' };
+
+ // While a gallery band backs the nav, the chrome wears its color and the
+ // content flips to the contrast color, so the two surfaces read as one.
+ const contentColor = navTint ? theme.palette.getContrastText(navTint) : theme.palette.text.primary;
+ const solidBg = navTint ?? navbg;
+ const navBorder = navTint ?? theme.palette.divider;
+ // The theme and tint washes cross-fade as separate layers.
+ const washGradientOf = (color: string) =>
+ `linear-gradient(to bottom, ${FADE_EASE.map(
+ (k, i) => `${alpha(color, tintAlpha * k)} ${(i * 100) / (FADE_EASE.length - 1)}%`
+ ).join(', ')})`;
+ const themeWash = washGradientOf(theme.palette.background.default);
+ const tintWash = washColor ? washGradientOf(washColor) : null;
+
+ return (
+
+ {/* Solid frosted glass — visible when not scrolled */}
+
+ {/* Progressive gradient blur fan — fades in on scroll */}
+ {BLUR_LAYERS.map((layer, i) => (
+
+ ))}
+
+ {tintWash && (
+
+ )}
+
+
+ {/* Mobile compact icon — shown while the nav search field is visible. The right
+ margin balances the burger button's own padding so the search field sits centered. */}
+
+
+ {/* Same mark height as the full toolbar logo */}
+
+
+
+ {/* Full logo — desktop always, mobile only while the nav search field is hidden. */}
+
+ {ToolbarContent ? : null}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/webui/src/layout/nav-search-field.tsx b/webui/src/layout/nav-search-field.tsx
new file mode 100644
index 000000000..7071529d5
--- /dev/null
+++ b/webui/src/layout/nav-search-field.tsx
@@ -0,0 +1,191 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import {
+ FocusEvent,
+ FunctionComponent,
+ KeyboardEvent,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState
+} from 'react';
+import { Box } from '@mui/material';
+import { useLocation } from 'react-router-dom';
+import { ExtensionSearchfield } from '../components/extension-searchfield';
+import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes';
+import { useSearch, SEARCH_DEBOUNCE_MS } from '../hooks/use-search';
+import { useSearchQuery } from '../context/search/search-context';
+import { useSearchFocus } from '../context/search/search-focus-context';
+import { usePageSearchBar } from '../context/search/page-search-bar-context';
+import { useSignalEffect } from '../hooks/use-signal-effect';
+import { useDebouncedCallback } from '../hooks/use-debounced-callback';
+import { useShortcut } from '../hooks/use-shortcut';
+
+export const NavSearchField: FunctionComponent = () => {
+ const { pathname } = useLocation();
+ const { query, setQuery } = useSearchQuery();
+ const { search, filter } = useSearch();
+ const { searchFocusSignal, resultsNavigationSignal, setSearchFocused } = useSearchFocus();
+ const { hasPageSearchBar, isPageSearchBarActive } = usePageSearchBar();
+ const inputRef = useRef(null);
+
+ // Suppress the show/hide fade on first paint. The home hero registers its page
+ // search bar in a layout effect, so this field starts visible and is hidden in
+ // the same commit — without this it would fade out from opacity 1 on load.
+ const [fadeReady, setFadeReady] = useState(false);
+ useEffect(() => {
+ const id = requestAnimationFrame(() => setFadeReady(true));
+ return () => cancelAnimationFrame(id);
+ }, []);
+
+ // Typing debounces navigation; Enter searches immediately. A route change
+ // drops any pending navigation (e.g. the user clicked a result mid-debounce).
+ const debouncedSearch = useDebouncedCallback(search, SEARCH_DEBOUNCE_MS);
+ useLayoutEffect(() => debouncedSearch.cancel, [pathname, debouncedSearch]);
+
+ // While a page search bar is registered, this field is only an opacity-0
+ // placeholder for the view-transition morph; `inert` removes it from the tab
+ // order and accessibility tree. `pendingFocus` holds a focus request that
+ // arrived in the commit that unregistered the page search bar (before this
+ // component re-rendered); it is honored here once the field is visible again.
+ const fieldRef = useRef(null);
+ const pendingFocus = useRef(false);
+ useLayoutEffect(() => {
+ if (fieldRef.current) {
+ fieldRef.current.inert = hasPageSearchBar;
+ }
+ if (!hasPageSearchBar && pendingFocus.current) {
+ pendingFocus.current = false;
+ inputRef.current?.focus({ preventScroll: true });
+ }
+ }, [hasPageSearchBar]);
+
+ // Take focus when requested — unless a page search bar is registered and owns focus instead.
+ useSignalEffect(
+ searchFocusSignal,
+ useCallback(() => {
+ if (isPageSearchBarActive()) {
+ return;
+ }
+ if (hasPageSearchBar) {
+ // Stale commit: the bar just unregistered; defer to the layout effect above.
+ pendingFocus.current = true;
+ return;
+ }
+ inputRef.current?.focus({ preventScroll: true });
+ }, [isPageSearchBarActive, hasPageSearchBar])
+ );
+
+ // The '/' shortcut asks whichever search field is active to take focus.
+ useShortcut({ key: '/', label: 'Focus search', order: 1, callback: searchFocusSignal.emit });
+
+ const handleNavSearch = useCallback(
+ (q: string) => {
+ setQuery(q);
+ // Emptying the field only re-searches on the search page (where empty
+ // means "show all"); elsewhere it shouldn't navigate away.
+ if (!q.trim() && pathname !== ExtensionListRoutes.SEARCH) {
+ debouncedSearch.cancel();
+ return;
+ }
+ debouncedSearch({ query: q });
+ },
+ [setQuery, debouncedSearch, pathname]
+ );
+
+ const handleNavSubmit = useCallback(
+ (q: string) => {
+ debouncedSearch.cancel();
+ // On the search page, Enter on an already-applied query opens the card
+ // under the cursor; on a fresh query it applies the search first.
+ if (pathname === ExtensionListRoutes.SEARCH && q.trim() === filter.query) {
+ resultsNavigationSignal.emit('open');
+ return;
+ }
+ search({ query: q });
+ },
+ [debouncedSearch, search, pathname, filter.query, resultsNavigationSignal.emit]
+ );
+
+ // Move cursor to end when the input gains focus (e.g. after view-transition morphs
+ // the hero search into this field — browsers select-all by default on programmatic focus)
+ const handleInputFocus = useCallback(
+ (e: FocusEvent) => {
+ setSearchFocused(true);
+ const { target } = e;
+ requestAnimationFrame(() => target.setSelectionRange(target.value.length, target.value.length));
+ },
+ [setSearchFocused]
+ );
+
+ const handleInputBlur = useCallback(() => setSearchFocused(false), [setSearchFocused]);
+
+ // On the search page the input and the results share one cursor: Up/Down
+ // step it through the results item by item while focus (and the text caret)
+ // stays in the field, and Enter opens it. Escape blurs the field.
+ const handleInputKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ // preventDefault stops WebKit/Blink from also clearing the search input.
+ e.preventDefault();
+ (e.target as HTMLInputElement).blur();
+ return;
+ }
+ if (pathname !== ExtensionListRoutes.SEARCH) {
+ return;
+ }
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ resultsNavigationSignal.emit(e.key === 'ArrowDown' ? 'next' : 'previous');
+ }
+ },
+ [resultsNavigationSignal.emit, pathname]
+ );
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/webui/src/layout/scroll-to-top.tsx b/webui/src/layout/scroll-to-top.tsx
new file mode 100644
index 000000000..35f955714
--- /dev/null
+++ b/webui/src/layout/scroll-to-top.tsx
@@ -0,0 +1,35 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, useLayoutEffect } from 'react';
+import { useLocation, useNavigationType } from 'react-router-dom';
+
+// BrowserRouter leaves window scroll untouched on navigation, so a page opened
+// from deep in a long list would start at that old offset. Reset to the top on
+// forward navigations only: POP (back/forward) keeps the browser's native
+// scroll restoration, and same-path param updates (e.g. search filters, which
+// replace in place) must not jump either — hence keying on pathname alone.
+// Links that swap content in place opt out via state `{ preserveScroll: true }`.
+export const ScrollToTop: FunctionComponent = () => {
+ const { pathname, state } = useLocation();
+ const navigationType = useNavigationType();
+ const preserveScroll = (state as { preserveScroll?: boolean } | null)?.preserveScroll;
+
+ useLayoutEffect(() => {
+ if (navigationType !== 'POP' && !preserveScroll) {
+ window.scrollTo({ top: 0, left: 0 });
+ }
+ }, [pathname]);
+
+ return null;
+};
diff --git a/webui/src/main.css b/webui/src/main.css
index fff817020..bb4a0ae44 100644
--- a/webui/src/main.css
+++ b/webui/src/main.css
@@ -1,3 +1,17 @@
+/* Box-shadow tokens: referenced by theme.tsx component overrides and component sx props.
+ Must be CSS vars (not theme values) because the theme itself uses them in string overrides. */
+:root {
+ --shadow: 0 1px 2px rgba(20, 20, 40, 0.04), 0 4px 14px rgba(20, 20, 40, 0.05);
+ --shadow-lg: 0 12px 44px rgba(20, 20, 40, 0.14);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 4px 18px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 16px 54px rgba(0, 0, 0, 0.6);
+ }
+}
+
html,
body {
height: 100%;
@@ -16,12 +30,57 @@ img {
overflow: hidden;
}
-@keyframes fadein {
+@keyframes fadeIn {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes popIn {
0% {
opacity: 0;
+ transform: scale(0.95) translateY(6px);
}
100% {
opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+}
+
+/* View transition for search bar morph. The moving group carries the morph;
+ the snapshots swap quickly and without transforms of their own — a full-length
+ crossfade doubles the field's text, and extra transforms offset the two pills. */
+::view-transition-group(vt-search) {
+ animation-duration: 200ms;
+ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+}
+::view-transition-old(vt-search) {
+ animation: 80ms ease-out both vt-fade-out;
+}
+::view-transition-new(vt-search) {
+ animation: 120ms ease-out 60ms both vt-fade-in;
+}
+
+/* Staggered fade-through between pages: the outgoing page is mostly gone
+ before the incoming one appears, so their contents never double up. */
+::view-transition-old(root) {
+ animation: 90ms ease-out both vt-fade-out;
+}
+::view-transition-new(root) {
+ animation: 140ms ease-out 50ms both vt-fade-in;
+}
+
+@keyframes vt-fade-out {
+ to {
+ opacity: 0;
+ }
+}
+@keyframes vt-fade-in {
+ from {
+ opacity: 0;
}
}
diff --git a/webui/src/main.tsx b/webui/src/main.tsx
index c20a9cb92..e26094ae7 100644
--- a/webui/src/main.tsx
+++ b/webui/src/main.tsx
@@ -29,7 +29,7 @@ import {
import { MainContext } from './context';
import { PageSettings } from './page-settings';
import { ErrorResponse } from './server-request';
-import { OtherPages } from './other-pages';
+import { AppLayout } from './layout/app-layout';
import '../src/main.css';
@@ -132,7 +132,7 @@ export const Main: FunctionComponent = props => {
}
/>
- } />
+ } />
{error ? (
import('./pages/user/user-settings').then(m => ({ default: m.UserSettings })));
-
-const ToolbarItem = styled(Box)({
- display: 'flex',
- alignItems: 'center'
-});
-
-const Wrapper = styled(Box)({
- display: 'flex',
- flexDirection: 'column',
- position: 'relative',
- minHeight: '100vh'
-});
-
-const Footer = styled('footer')(({ theme }: { theme: Theme }) => ({
- position: 'fixed',
- bottom: 0,
- width: '100%',
- padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
- backgroundColor: theme.palette.background.paper,
- boxShadow: '0px -2px 6px 0px rgba(0, 0, 0, 0.5)',
- backgroundImage:
- theme.palette.mode == 'dark'
- ? 'linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08))'
- : undefined
-}));
-
-export const OtherPages: FunctionComponent = props => {
- const { pageSettings } = useContext(MainContext);
- const {
- additionalRoutes: AdditionalRoutes,
- banner: BannerComponent,
- footer: FooterComponent,
- toolbarContent: ToolbarContent
- } = pageSettings.elements;
-
- const [isBannerOpen, setIsBannerOpen] = useState(false);
- const [isFooterExpanded, setIsFooterExpanded] = useState(false);
-
- useEffect(() => {
- // Check a cookie to determine whether a banner should be shown
- const banner = pageSettings.elements.banner;
- if (banner) {
- let open = true;
- if (banner.cookie) {
- const bannerClosedCookie = getCookieValueByKey(banner.cookie.key);
- if (bannerClosedCookie === banner.cookie.value) {
- open = false;
- }
- }
- setIsBannerOpen(open);
- }
- }, []);
-
- const onDismissBannerButtonClick = () => {
- const onClose = pageSettings.elements.banner?.props?.onClose;
- if (onClose) {
- onClose();
- }
- const cookie = pageSettings.elements.banner?.cookie;
- if (cookie) {
- setCookie(cookie);
- }
- setIsBannerOpen(false);
- };
-
- const getContentPadding = (): number => {
- const footerHeight = pageSettings.elements.footer?.props.footerHeight;
- return footerHeight ? footerHeight + 24 : 0;
- };
-
- return (
-
-
-
- {ToolbarContent ? : null}
-
-
-
-
-
- {BannerComponent ? (
-
-
-
- ) : null}
-
-
-
- } />
- }
- />
- }
- />
- } />
- } />
- } />
- {AdditionalRoutes ?? null}
- } />
-
-
-
- {FooterComponent ? (
- setIsFooterExpanded(true)} onMouseLeave={() => setIsFooterExpanded(false)}>
-
-
- ) : null}
-
- );
-};
-
-export interface OtherPagesProps {
- user?: UserData;
- userLoading: boolean;
-}
diff --git a/webui/src/page-settings.ts b/webui/src/page-settings.ts
index 6108f42fc..74f480021 100644
--- a/webui/src/page-settings.ts
+++ b/webui/src/page-settings.ts
@@ -10,9 +10,100 @@
import { ComponentType, ReactNode } from 'react';
import { SxProps, Theme } from '@mui/material/styles';
-import { Extension, NamespaceDetails } from './extension-registry-types';
+import { Extension, NamespaceDetails, SortBy } from './extension-registry-types';
import { Cookie } from './utils';
+export interface FooterLink {
+ label: ReactNode;
+ href: string;
+ /** Open in a new tab. */
+ external?: boolean;
+}
+
+export interface FooterColumn {
+ heading: ReactNode;
+ links: FooterLink[];
+}
+
+export interface FooterSocialLink {
+ /** Accessible label / tooltip for the icon button. */
+ title: string;
+ href: string;
+ icon: ReactNode;
+}
+
+/**
+ * Structured, data-driven footer rendered by the library chrome (brand, link
+ * columns and social icons). Every field is consumer-provided branding and may
+ * be a plain string or any React node.
+ */
+export interface StructuredFooterSettings {
+ brand?: {
+ logo?: ReactNode;
+ name: ReactNode;
+ description?: ReactNode;
+ };
+ columns?: FooterColumn[];
+ social?: FooterSocialLink[];
+ copyright?: ReactNode;
+ /** Extra node appended to the footer's bottom bar, next to the built-in controls. */
+ extra?: ReactNode;
+}
+
+/**
+ * Legacy footer: a fully custom component fixed to the bottom of the viewport,
+ * receiving an `expanded` flag that is true while hovered. Kept for backward
+ * compatibility with consumers written before {@link StructuredFooterSettings}.
+ */
+export interface CustomFooterSettings {
+ content: ComponentType<{ expanded: boolean }>;
+ props?: {
+ /** Collapsed footer height; the page content is bottom-padded by it to stay clear of the fixed footer. */
+ footerHeight?: number;
+ };
+}
+
+/**
+ * Footer configuration. Provide {@link StructuredFooterSettings} to feed the
+ * built-in footer chrome, a component to replace the footer entirely (rendered
+ * bare at the bottom of the page), or {@link CustomFooterSettings} (with
+ * `content`) for the legacy hover-expanding chrome.
+ */
+export type FooterSettings = ComponentType | CustomFooterSettings | StructuredFooterSettings;
+
+/** A curated row of extensions on the home page, fetched with the given ordering. */
+export interface HomeCuratedSection {
+ title: string;
+ subtitle: string;
+ sortBy: SortBy;
+}
+
+export interface HomeInvolvementCard {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ href: string;
+ label: string;
+}
+
+/** Consumer-provided content for the built-in home page (branding and curated data). */
+export interface HomeSettings {
+ popularSearches?: string[];
+ curatedSections?: HomeCuratedSection[];
+ involvement?: {
+ heading?: string;
+ cards: HomeInvolvementCard[];
+ };
+}
+
+/**
+ * Home route configuration. Provide {@link HomeSettings} to feed the built-in
+ * home page, or a component to replace it entirely. The built-in sections are
+ * exported so a replacement can be composed from them; each boxes itself, so
+ * don't wrap them in another width-constraining container.
+ */
+export type HomePageSettings = HomeSettings | ComponentType;
+
export interface PageSettings {
pageTitle: string;
themeType?: 'light' | 'dark';
@@ -24,12 +115,8 @@ export interface PageSettings {
toolbarContent?: ComponentType;
defaultMenuContent?: ComponentType;
mobileMenuContent?: ComponentType;
- footer?: {
- content: ComponentType<{ expanded: boolean }>;
- props: {
- footerHeight?: number;
- };
- };
+ footer?: FooterSettings;
+ home?: HomePageSettings;
searchHeader?: ComponentType;
reportAbuse?: ComponentType<{ extension: Extension; sx?: SxProps }>;
claimNamespace?: ComponentType<{ extension: Extension; sx?: SxProps }>;
diff --git a/webui/src/pages/admin-dashboard/extension-admin.tsx b/webui/src/pages/admin-dashboard/extension-admin.tsx
index 28be391a3..20fefd974 100644
--- a/webui/src/pages/admin-dashboard/extension-admin.tsx
+++ b/webui/src/pages/admin-dashboard/extension-admin.tsx
@@ -15,7 +15,6 @@ import { Button, Typography } from '@mui/material';
import { MainContext } from '../../context';
import { SearchListContainer } from './search-list-container';
import { StyledInput } from './namespace-input';
-import { ExtensionListSearchfield } from '../extension-list/extension-list-searchfield';
import { useAdminExtension, useDeleteExtension } from './use-extension-admin';
import { ExtensionDetailView } from '../../components/extension/extension-detail-view';
import { AdminDashboardRoutes } from './admin-dashboard-routes';
@@ -88,12 +87,12 @@ export const ExtensionAdmin: FunctionComponent = () => {
hideIconButton={true}
autoFocus={true}
/>,
-
-
+
({
+ minHeight: 0,
+ minWidth: 0,
+ padding: '0.4375rem 0.8125rem',
+ borderRadius: '999px',
+ border: `1px solid ${theme.palette.divider}`,
+ backgroundColor: alpha(theme.palette.surface2, 0.7),
+ backdropFilter: 'blur(2px) saturate(1.8)',
+ color: theme.palette.text.secondary,
+ fontSize: '0.8125rem',
+ fontWeight: 500,
+ transition: 'border-color 0.14s, color 0.14s, background 0.14s',
+ // Still translucent so the blur fan shows through; the border carries the emphasis.
+ '&.Mui-selected': {
+ backgroundColor: alpha(theme.palette.secondary.main, 0.7),
+ borderColor: theme.palette.secondary.main,
+ color: theme.palette.secondary.contrastText,
+ fontWeight: 600
+ },
+ '&:not(.Mui-selected)': accentHover(theme),
+ ...focusOutline(theme)
+})) as typeof Tab;
const inlineLinkStyle = {
display: 'contents',
@@ -183,22 +213,25 @@ const LicenseLink: FunctionComponent<{
return <>{extension.license || 'Unlicensed'}>;
};
-const compactNumber = new Intl.NumberFormat(undefined, {
- notation: 'compact',
- compactDisplay: 'short'
-} as Intl.NumberFormatOptions);
-
const ExtensionHeaderInfo: FunctionComponent<{
extension: Extension;
headerTextColor: string;
}> = ({ extension, headerTextColor }) => {
- const downloadCountFormatted = compactNumber.format(extension.downloadCount || 0);
- const reviewCountFormatted = compactNumber.format(extension.reviewCount || 0);
+ const downloadCountFormatted = formatCompactNumber(extension.downloadCount || 0);
+ const reviewCountFormatted = formatCompactNumber(extension.reviewCount || 0);
return (
-
+
-
+
{extension.displayName ?? extension.name}
@@ -287,9 +320,37 @@ const ExtensionHeaderInfo: FunctionComponent<{
);
};
+// The luma CSS grayscale() resolves a color to; deprecated bands paint through that filter.
+const grayscaleOf = (color: string): string => {
+ const [r, g, b] = decomposeColor(color).values;
+ const y = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
+ return `rgb(${y}, ${y}, ${y})`;
+};
+
+// Declares the header band as the nav's tint region: its gallery color (null
+// for default bands) and how deep it runs. The nav does the switching; the
+// resize observer keeps the depth fresh as the band's content reflows.
+const useNavTintFromBand = (color: string | null, bandRef: RefObject): void => {
+ const setTint = useSetExtensionTint();
+ useEffect(() => {
+ const band = bandRef.current;
+ if (!band) return;
+ // Fires once on observe, then on size changes.
+ const observer = new ResizeObserver(() =>
+ setTint({ color, depth: band.getBoundingClientRect().bottom + window.scrollY })
+ );
+ observer.observe(band);
+ return () => {
+ observer.disconnect();
+ setTint(null);
+ };
+ }, [color, bandRef, setTint]);
+};
+
const ExtensionHeader: FunctionComponent<{
extension: Extension;
-}> = ({ extension }) => {
+ bandRef: RefObject;
+}> = ({ extension, bandRef }) => {
const theme = useTheme();
const { pageSettings } = useContext(MainContext);
@@ -304,30 +365,49 @@ const ExtensionHeader: FunctionComponent<{
}
const headerTextColor = theme.palette.getContrastText(headerColor);
+ const usesDefaultBg = !extension.galleryColor;
+
+ // Only real gallery colors tint the nav; over default bands it keeps its
+ // theme glass, which is visually flush with bg2 already.
+ useNavTintFromBand(usesDefaultBg ? null : extension.deprecated ? grayscaleOf(headerColor) : headerColor, bandRef);
return (
-
-
+
+
+
-
@@ -345,9 +425,29 @@ export const ExtensionDetail: FunctionComponent = () => {
const effectiveVersion = isTabSegment(version) ? undefined : version;
const activeTab = parseTab(version);
+ // Tab switches preserve scroll (see ScrollToTop); when scrolled deep, glide
+ // up so the new panel starts under the pinned pills.
+ const bandRef = useRef(null);
+ const prevTab = useRef(activeTab);
+ useEffect(() => {
+ if (prevTab.current === activeTab) return;
+ prevTab.current = activeTab;
+ const band = bandRef.current;
+ if (!band) return;
+ const pin = band.getBoundingClientRect().bottom + window.scrollY - NAVBAR_HEIGHT_PX;
+ // Rest above the pin point
+ const target = Math.max(0, pin);
+ if (window.scrollY > target) {
+ window.scrollTo({ top: target });
+ }
+ }, [activeTab]);
+
// React Router v6 returns a possibly undefined type for params, but our route configuration guarantees these will be defined.
const { loading, error, extension, reload } = useExtensionDetail(namespace!, name!, target!, effectiveVersion!);
+ // Tab switches keep the scroll position (the effect above adjusts it).
+ const goToTab = useCallback((path: string) => navigate(path, { state: { preserveScroll: true } }), [navigate]);
+
const navigateToVersion = useCallback(
(selectedVersion: string) => {
if (!namespace || !name) return;
@@ -360,11 +460,34 @@ export const ExtensionDetail: FunctionComponent = () => {
[navigate, namespace, name, target]
);
- if (!namespace || !name) return null;
+ // Computed before the early return so the shortcut hooks below run unconditionally.
+ const basePath = namespace && name ? buildExtensionPath(namespace, name, target) : '';
+ const changesPath = namespace && name ? buildExtensionPath(namespace, name, target, ExtensionTab.CHANGES) : '';
+ const reviewsPath = namespace && name ? buildExtensionPath(namespace, name, target, ExtensionTab.REVIEWS) : '';
+
+ useShortcut({
+ key: 'o',
+ label: 'Extension overview',
+ order: 40,
+ callback: () => goToTab(basePath),
+ enabled: !!basePath
+ });
+ useShortcut({
+ key: 'c',
+ label: 'Extension changelog',
+ order: 50,
+ callback: () => goToTab(changesPath),
+ enabled: !!basePath
+ });
+ useShortcut({
+ key: 'r',
+ label: 'Extension reviews',
+ order: 60,
+ callback: () => goToTab(reviewsPath),
+ enabled: !!basePath
+ });
- const basePath = buildExtensionPath(namespace, name, target);
- const reviewsPath = buildExtensionPath(namespace, name, target, ExtensionTab.REVIEWS);
- const changesPath = buildExtensionPath(namespace, name, target, ExtensionTab.CHANGES);
+ if (!namespace || !name) return null;
let overviewPath = basePath;
if (version && !isTabSegment(version)) {
@@ -381,39 +504,93 @@ export const ExtensionDetail: FunctionComponent = () => {
{extension && (
<>
-
+
-
-
+
+ Overviewo
+
+ }
component={RouteLink}
to={overviewPath}
+ state={{ preserveScroll: true }}
+ />
+
+ Changesc
+
+ }
+ component={RouteLink}
+ to={changesPath}
+ state={{ preserveScroll: true }}
/>
-
-
+ Ratings & Reviewsr
+
+ }
component={RouteLink}
to={reviewsPath}
+ state={{ preserveScroll: true }}
/>
-
- }
- />
- }
- />
-
- }
- />
-
+ {/* Owns the space below the pinned pills for every tab, so the panels
+ don't each set their own; min-height keeps the preserved scroll
+ position valid while a panel loads. */}
+
+
+ }
+ />
+ }
+ />
+
+ }
+ />
+
+
>
)}
diff --git a/webui/src/pages/extension-detail/extension-rating-stars.tsx b/webui/src/pages/extension-detail/extension-rating-stars.tsx
index 1e2b13ffb..77ae8e56e 100644
--- a/webui/src/pages/extension-detail/extension-rating-stars.tsx
+++ b/webui/src/pages/extension-detail/extension-rating-stars.tsx
@@ -23,11 +23,11 @@ export const ExtensionRatingStars: FunctionComponent
const starsNumber = props.number;
const fontSize = props.fontSize ?? 'medium';
if (i <= starsNumber) {
- return ;
+ return ;
}
if (i > starsNumber && i - 1 < starsNumber) {
return (
-
+
diff --git a/webui/src/pages/extension-list/extension-list-container.tsx b/webui/src/pages/extension-list/extension-list-container.tsx
deleted file mode 100644
index b5af72e50..000000000
--- a/webui/src/pages/extension-list/extension-list-container.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-/********************************************************************************
- * Copyright (c) 2019 TypeFox and others
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License v. 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0.
- *
- * SPDX-License-Identifier: EPL-2.0
- ********************************************************************************/
-
-import { FunctionComponent, useEffect, useState } from 'react';
-import { Box } from '@mui/material';
-import { useLocation } from 'react-router-dom';
-import { addQuery } from '../../utils';
-import { ExtensionCategory, SortOrder, SortBy } from '../../extension-registry-types';
-import { ExtensionList } from './extension-list';
-import { ExtensionListHeader } from './extension-list-header';
-
-export const ExtensionListContainer: FunctionComponent = () => {
- const [searchQuery, setSearchQuery] = useState('');
- const [category, setCategory] = useState('');
- const [resultNumber, setResultNumber] = useState(0);
- const [sortBy, setSortBy] = useState('relevance');
- const [sortOrder, setSortOrder] = useState('desc');
- const [searchDebounceTime, setSearchDebounceTime] = useState(0);
-
- const { pathname, search } = useLocation();
-
- useEffect(() => {
- const searchParams = new URLSearchParams(search);
- setSearchQuery(searchParams.get('search') ?? '');
- setCategory((searchParams.get('category') as ExtensionCategory) ?? '');
- setSortBy((searchParams.get('sortBy') as SortBy) ?? 'relevance');
- setSortOrder((searchParams.get('sortOrder') as SortOrder) ?? 'desc');
- }, []);
-
- const onSearchChanged = (searchQuery: string): void => {
- setSearchQuery(searchQuery);
- setSearchDebounceTime(1000);
- updateURL(searchQuery, category, sortBy, sortOrder);
- };
-
- const onSearchSubmit = (searchQuery: string): void => {
- setSearchQuery(searchQuery);
- setSearchDebounceTime(0);
- };
-
- const onCategoryChanged = (category: ExtensionCategory | ''): void => {
- setCategory(category);
- updateURL(searchQuery, category, sortBy, sortOrder);
- };
-
- const onSortByChanged = (sortBy: SortBy): void => {
- setSortBy(sortBy);
- updateURL(searchQuery, category, sortBy, sortOrder);
- };
-
- const onSortOrderChanged = (sortOrder: SortOrder): void => {
- setSortOrder(sortOrder);
- updateURL(searchQuery, category, sortBy, sortOrder);
- };
-
- const updateURL = (
- searchQuery: string,
- category: ExtensionCategory | '',
- sortBy?: SortBy,
- sortOrder?: SortOrder
- ): void => {
- const queries: { key: string; value: string }[] = [];
- if (searchQuery) {
- queries.push({ key: 'search', value: searchQuery });
- }
- if (category) {
- queries.push({ key: 'category', value: category });
- }
- if (sortBy) {
- queries.push({ key: 'sortBy', value: sortBy });
- }
- if (sortOrder) {
- queries.push({ key: 'sortOrder', value: sortOrder });
- }
- const url = addQuery('', queries) || pathname;
- history.replaceState(null, '', url);
- };
-
- const handleUpdate = (resultNumber: number): void => setResultNumber(resultNumber);
-
- return (
-
-
-
-
- );
-};
diff --git a/webui/src/pages/extension-list/extension-list-header.tsx b/webui/src/pages/extension-list/extension-list-header.tsx
deleted file mode 100644
index 793d93885..000000000
--- a/webui/src/pages/extension-list/extension-list-header.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-/********************************************************************************
- * Copyright (c) 2019 TypeFox and others
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License v. 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0.
- *
- * SPDX-License-Identifier: EPL-2.0
- ********************************************************************************/
-
-import { ChangeEvent, FunctionComponent, KeyboardEvent, useContext, useEffect, useState } from 'react';
-import { Box, Paper, InputBase, Select, MenuItem, Container, SelectChangeEvent } from '@mui/material';
-import { ExtensionCategory, SortBy, SortOrder } from '../../extension-registry-types';
-import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
-import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
-import { ExtensionListSearchfield } from './extension-list-searchfield';
-import { MainContext } from '../../context';
-
-export const ExtensionListHeader: FunctionComponent = props => {
- const [categories, setCategories] = useState([]);
- const [category, setCategory] = useState('');
- const [sortBy, setSortBy] = useState('relevance');
- const [sortOrder, setSortOrder] = useState('desc');
- const context = useContext(MainContext);
-
- useEffect(() => {
- const categories = Array.from(context.service.getCategories());
- categories.sort((a, b) => {
- if (a === b) return 0;
- if (a === 'Other') return 1;
- if (b === 'Other') return -1;
- return a.localeCompare(b);
- });
-
- setCategories(categories);
- setCategory(props.category ?? '');
- setSortBy(props.sortBy);
- setSortOrder(props.sortOrder);
- }, []);
-
- useEffect(() => {
- setCategory(props.category ?? '');
- setSortBy(props.sortBy);
- setSortOrder(props.sortOrder);
- }, [props.category, props.sortBy, props.sortOrder]);
-
- const handleCategoryChange = (event: SelectChangeEvent) => {
- const category = (event.target.value as ExtensionCategory) ?? '';
- setCategory(category);
- props.onCategoryChanged(category);
- };
-
- const handleSearchChange = (value: string) => {
- props.onSearchChanged(value);
- };
-
- const handleSearchSubmit = (value: string) => {
- props.onSearchSubmit(value);
- };
-
- const handleSortByChange = (event: ChangeEvent) => {
- const sortBy = event.target.value as SortBy;
- setSortBy(sortBy);
- props.onSortByChanged(sortBy);
- };
-
- const handleSortOrderChange = () => {
- const newSortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
- setSortOrder(newSortOrder);
- props.onSortOrderChanged(newSortOrder);
- };
-
- const renderValue = (value: string) => {
- return value === '' ? (
-
- All Categories
-
- ) : (
- value
- );
- };
-
- const SearchHeader = context.pageSettings.elements.searchHeader;
- return (
-
-
- {SearchHeader ? : ''}
-
-
-
-
- }>
- All Categories
- {categories.map(c => {
- return (
-
- {c}
-
- );
- })}
-
-
-
-
- {`${props.resultNumber} Result${props.resultNumber !== 1 ? 's' : ''}`}
-
-
- Sort by
- }
- value={sortBy}
- onChange={handleSortByChange}>
- Relevance
- Date
- Downloads
- Rating
-
-
- {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleSortOrderChange();
- }
- }}
- onClick={handleSortOrderChange}>
- {sortOrder === 'asc' ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- );
-};
-
-export interface ExtensionListHeaderProps {
- onSearchChanged: (s: string) => void;
- onSearchSubmit: (s: string) => void;
- onCategoryChanged: (c: ExtensionCategory) => void;
- onSortByChanged: (sb: SortBy) => void;
- onSortOrderChanged: (so: SortOrder) => void;
- searchQuery?: string;
- category?: ExtensionCategory | '';
- sortBy: SortBy;
- sortOrder: SortOrder;
- resultNumber: number;
-}
diff --git a/webui/src/pages/extension-list/extension-list-item.tsx b/webui/src/pages/extension-list/extension-list-item.tsx
deleted file mode 100644
index 435ee34c3..000000000
--- a/webui/src/pages/extension-list/extension-list-item.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-/********************************************************************************
- * Copyright (c) 2019 TypeFox and others
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License v. 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0.
- *
- * SPDX-License-Identifier: EPL-2.0
- ********************************************************************************/
-
-import { FunctionComponent } from 'react';
-import { Link as RouteLink } from 'react-router-dom';
-import { Paper, Typography, Box, Grid, Fade } from '@mui/material';
-import SaveAltIcon from '@mui/icons-material/SaveAlt';
-import { ExtensionDetailRoutes } from '../extension-detail/extension-detail-routes';
-import { SearchEntry } from '../../extension-registry-types';
-import { ExtensionIcon } from '../../components/extension/extension-icon';
-import { ExtensionRatingStars } from '../extension-detail/extension-rating-stars';
-import { createRoute } from '../../utils';
-
-export const ExtensionListItem: FunctionComponent = props => {
- const { extension, filterSize, idx } = props;
- const route = createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name]);
- const numberFormat = new Intl.NumberFormat(undefined, { notation: 'compact', compactDisplay: 'short' } as any);
- const downloadCountFormatted = numberFormat.format(extension.downloadCount ?? 0);
- return (
-
-
-
- *': {
- '&:not(:last-child)': {
- marginBottom: '.5rem'
- }
- },
- opacity: extension.deprecated ? 0.5 : undefined,
- filter: extension.deprecated ? 'grayscale(100%)' : undefined
- }}>
-
-
-
-
-
- {extension.displayName ?? extension.name}
-
-
-
-
- {extension.namespace}
-
-
- {extension.version}
-
-
-
-
-
- {downloadCountFormatted != '0' && (
- <>
- {downloadCountFormatted}
- >
- )}
-
-
-
-
-
- );
-};
-
-export interface ExtensionListItemProps {
- extension: SearchEntry;
- idx: number;
- filterSize: number;
-}
diff --git a/webui/src/pages/extension-list/extension-list-routes.ts b/webui/src/pages/extension-list/extension-list-routes.ts
index 3ff30749f..ed80c4674 100644
--- a/webui/src/pages/extension-list/extension-list-routes.ts
+++ b/webui/src/pages/extension-list/extension-list-routes.ts
@@ -15,4 +15,5 @@ import { createRoute } from '../../utils';
export namespace ExtensionListRoutes {
export const MAIN = createRoute([]);
+ export const SEARCH = createRoute(['search']);
}
diff --git a/webui/src/pages/extension-list/extension-list-searchfield.tsx b/webui/src/pages/extension-list/extension-list-searchfield.tsx
deleted file mode 100644
index 371839533..000000000
--- a/webui/src/pages/extension-list/extension-list-searchfield.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-/********************************************************************************
- * Copyright (c) 2020 TypeFox and others
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License v. 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0.
- *
- * SPDX-License-Identifier: EPL-2.0
- ********************************************************************************/
-
-import { ChangeEvent, FunctionComponent, KeyboardEvent, useContext } from 'react';
-import SearchIcon from '@mui/icons-material/Search';
-import { Paper, IconButton, InputBase } from '@mui/material';
-import { MainContext } from '../../context';
-
-interface ExtensionListSearchfieldProps {
- onSearchChanged: (s: string) => void;
- onSearchSubmit?: (s: string) => void;
- searchQuery?: string;
- placeholder: string;
- hideIconButton?: boolean;
- error?: boolean;
- autoFocus?: boolean;
-}
-
-export const ExtensionListSearchfield: FunctionComponent = props => {
- const { pageSettings } = useContext(MainContext);
-
- const handleSearchChange = (event: ChangeEvent) => {
- props.onSearchChanged(event.target.value);
- };
-
- const handleSearchButtonClick = () => {
- if (props.onSearchSubmit) {
- props.onSearchSubmit(props.searchQuery ?? '');
- }
- };
-
- const searchIconColor = pageSettings?.themeType === 'dark' ? '#111111' : '#ffffff';
- return (
-
- {
- if (e.key === 'Enter' && props.onSearchSubmit) {
- props.onSearchSubmit(props.searchQuery ?? '');
- }
- }}
- />
-
- Search for Name, Tags or Description
-
- {props.hideIconButton ? (
- ''
- ) : (
-
-
-
- )}
-
- );
-};
diff --git a/webui/src/pages/extension-list/extension-list.tsx b/webui/src/pages/extension-list/extension-list.tsx
deleted file mode 100644
index b30d5a0fd..000000000
--- a/webui/src/pages/extension-list/extension-list.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-/********************************************************************************
- * Copyright (c) 2019 TypeFox and others
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License v. 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0.
- *
- * SPDX-License-Identifier: EPL-2.0
- ********************************************************************************/
-
-import { FunctionComponent, useContext, useEffect, useRef, useState } from 'react';
-import InfiniteScroll from 'react-infinite-scroller';
-import { Box, Grid, CircularProgress, Container } from '@mui/material';
-import { ExtensionListItem } from './extension-list-item';
-import { isError, SearchEntry, SearchResult } from '../../extension-registry-types';
-import { ExtensionFilter } from '../../extension-registry-service';
-import { debounce } from '../../utils';
-import { DelayedLoadIndicator } from '../../components/delayed-load-indicator';
-import { MainContext } from '../../context';
-
-export const ExtensionList: FunctionComponent = props => {
- const abortController = useRef(new AbortController());
- const cancellationToken = useRef<{ timeout?: number }>({});
- const enableLoadMore = useRef(false);
- const lastRequestedPage = useRef(0);
- const pageOffset = useRef(0);
- const filterSize = useRef(props.filter.size ?? 10);
- const context = useContext(MainContext);
- const [extensions, setExtensions] = useState([]);
- const [extensionKeys, setExtensionKeys] = useState>(new Set());
- const [appliedFilter, setAppliedFilter] = useState();
- const [hasMore, setHasMore] = useState(false);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- enableLoadMore.current = true;
- return () => {
- abortController.current.abort();
- clearTimeout(cancellationToken.current.timeout);
- enableLoadMore.current = false;
- };
- }, []);
-
- useEffect(() => {
- filterSize.current = props.filter.size ?? filterSize.current;
- debounce(
- async () => {
- try {
- const result = await context.service.search(abortController.current, props.filter);
- if (isError(result)) {
- throw result;
- }
-
- const searchResult = result as SearchResult;
- props.onUpdate(searchResult.totalSize);
- const actualSize = searchResult.extensions.length;
- pageOffset.current = lastRequestedPage.current;
- const extensionKeys = new Set();
- for (const ext of searchResult.extensions) {
- extensionKeys.add(`${ext.namespace}.${ext.name}`);
- }
-
- setExtensions(searchResult.extensions);
- setExtensionKeys(extensionKeys);
- setAppliedFilter(props.filter);
- setHasMore(actualSize < searchResult.totalSize && actualSize > 0);
- } catch (err) {
- context.handleError(err);
- } finally {
- setLoading(false);
- }
- },
- cancellationToken.current,
- props.debounceTime
- );
- }, [props.filter.category, props.filter.query, props.filter.sortBy, props.filter.sortOrder, props.debounceTime]);
-
- const loadMore = async (p: number): Promise => {
- setLoading(true);
- setHasMore(false);
- lastRequestedPage.current = p;
- const filter = copyFilter(appliedFilter as ExtensionFilter);
- if (!isSameFilter(props.filter, filter)) {
- return;
- }
- try {
- filter.offset = (p - pageOffset.current) * filterSize.current;
- const result = await context.service.search(abortController.current, filter);
- if (isError(result)) {
- throw result;
- }
-
- const newExtensions: SearchEntry[] = [];
- const newExtensionKeys = new Set();
- newExtensions.push(...extensions);
- extensionKeys.forEach(key => newExtensionKeys.add(key));
- const searchResult = result as SearchResult;
- if (enableLoadMore.current && isSameFilter(props.filter, filter)) {
- // Check for duplicate keys to avoid problems due to asynchronous user edit / loadMore call
- for (const ext of searchResult.extensions) {
- const key = `${ext.namespace}.${ext.name}`;
- if (!extensionKeys.has(key)) {
- newExtensions.push(ext);
- newExtensionKeys.add(key);
- }
- }
-
- setExtensions(newExtensions);
- setExtensionKeys(newExtensionKeys);
- setHasMore(extensions.length < searchResult.totalSize && searchResult.extensions.length > 0);
- }
- } catch (err) {
- context.handleError(err);
- } finally {
- setLoading(false);
- }
- };
-
- const isSameFilter = (f1: ExtensionFilter, f2: ExtensionFilter): boolean => {
- return (
- f1.category === f2.category &&
- f1.query === f2.query &&
- f1.sortBy === f2.sortBy &&
- f1.sortOrder === f2.sortOrder
- );
- };
-
- const copyFilter = (f: ExtensionFilter): ExtensionFilter => {
- return {
- query: f.query,
- category: f.category || '',
- size: f.size,
- offset: f.offset,
- sortBy: f.sortBy,
- sortOrder: f.sortOrder
- };
- };
-
- const extensionList = extensions.map((ext, idx) => (
-
- ));
-
- const loader = (
-
-
-
- );
-
- return (
- <>
-
-
-
-
- {extensionList}
-
-
-
- >
- );
-};
-
-export interface ExtensionListProps {
- filter: ExtensionFilter;
- debounceTime: number;
- onUpdate: (resultNumber: number) => void;
-}
diff --git a/webui/src/pages/home/browse-categories.tsx b/webui/src/pages/home/browse-categories.tsx
new file mode 100644
index 000000000..c03f3de3b
--- /dev/null
+++ b/webui/src/pages/home/browse-categories.tsx
@@ -0,0 +1,82 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent } from 'react';
+import { Box } from '@mui/material';
+import { ExtensionCategory } from '../../extension-registry-types';
+import { CATEGORY_ICONS, DefaultCategoryIcon } from '../../components/categories';
+import { CategoryPill } from '../../components/category-pill';
+import { CategoryCard } from '../../components/category-card';
+import { Section, Eyebrow } from '../../components/page-primitives';
+import { useSearch } from '../../hooks/use-search';
+import { useHomeCategories } from './use-home-data';
+
+export interface BrowseCategoriesProps {
+ /** Categories to offer; defaults to the registry's browsable home list. */
+ categories?: ExtensionCategory[];
+ /** Category click handler; defaults to opening the search page filtered to it. */
+ onSelect?: (category: ExtensionCategory) => void;
+}
+
+/** "Browse by category" section: a horizontal pill row on mobile, a card grid on desktop. */
+export const BrowseCategories: FunctionComponent = props => {
+ const homeCategories = useHomeCategories();
+ const { search } = useSearch();
+ const categories = props.categories ?? homeCategories;
+ const onSelect = props.onSelect ?? ((category: ExtensionCategory) => search({ query: '', category }));
+ if (categories.length === 0) {
+ return null;
+ }
+ return (
+
+ Browse by category
+
+ {categories.map(cat => (
+ onSelect(cat)}
+ />
+ ))}
+
+
+ {categories.map(cat => (
+ onSelect(cat)}
+ />
+ ))}
+
+
+ );
+};
diff --git a/webui/src/pages/home/curated-sections.tsx b/webui/src/pages/home/curated-sections.tsx
new file mode 100644
index 000000000..12fe505d9
--- /dev/null
+++ b/webui/src/pages/home/curated-sections.tsx
@@ -0,0 +1,105 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent } from 'react';
+import { Box, Typography } from '@mui/material';
+import { ExtensionCard } from '../../components/extension-card';
+import { Section } from '../../components/page-primitives';
+import { HomeCuratedSection } from '../../page-settings';
+import { useSearch } from '../../hooks/use-search';
+import { CURATED_SIZE, DEFAULT_CURATED_SECTIONS, useCuratedRows } from './use-home-data';
+
+export interface CuratedSectionsProps {
+ /** Rows to fetch and render; defaults to "Most downloaded" and "Recently updated". */
+ sections?: HomeCuratedSection[];
+ /** "See all" click handler; defaults to opening the search page unfiltered. */
+ onSeeAll?: () => void;
+}
+
+/** Renders the curated extension rows (e.g. "Most downloaded"), skipping empty/loading ones. */
+export const CuratedSections: FunctionComponent = props => {
+ const rows = useCuratedRows(props.sections ?? DEFAULT_CURATED_SECTIONS);
+ const { search } = useSearch();
+ return (
+ <>
+ {rows.map(row => {
+ // Hide rows that loaded empty (e.g. a failed request).
+ if (!row.loading && row.extensions.length === 0) {
+ return null;
+ }
+ return (
+
+
+
+
+ {row.title}
+
+
+ {row.subtitle}
+
+
+ search({ query: '', sortBy: row.sortBy }))}
+ sx={{
+ background: 'none',
+ border: 'none',
+ color: 'secondary.light',
+ fontSize: '0.875rem',
+ fontWeight: 600,
+ cursor: 'pointer'
+ }}>
+ See all →
+
+
+
+ {/* Fixed index-keyed slots so cards stay mounted across the swap and don't re-fade. */}
+ {Array.from({ length: row.loading ? CURATED_SIZE : row.extensions.length }, (_, idx) => (
+
+ ))}
+
+
+ );
+ })}
+ >
+ );
+};
diff --git a/webui/src/pages/home/get-involved.tsx b/webui/src/pages/home/get-involved.tsx
new file mode 100644
index 000000000..d65c5cdfb
--- /dev/null
+++ b/webui/src/pages/home/get-involved.tsx
@@ -0,0 +1,125 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, ReactNode } from 'react';
+import { Box, Typography } from '@mui/material';
+import { styled } from '@mui/material/styles';
+import type { Theme } from '@mui/material/styles';
+import { Link as RouteLink } from 'react-router-dom';
+import { HomeInvolvementCard } from '../../page-settings';
+import { Section, Eyebrow, focusOutline } from '../../components/page-primitives';
+
+const GetInvolvedCard = styled(Box)(({ theme }) => ({
+ backgroundColor: theme.palette.background.paper,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: '16px',
+ padding: '1.5rem',
+ display: 'flex',
+ flexDirection: 'column'
+}));
+
+const getInvolvedLinkStyles = ({ theme }: { theme: Theme }) => ({
+ fontSize: '0.8125rem',
+ fontWeight: 600,
+ color: theme.palette.secondary.light,
+ textDecoration: 'none',
+ '&:hover': { textDecoration: 'underline' },
+ ...focusOutline(theme)
+});
+
+const GetInvolvedAnchorLink = styled('a')(getInvolvedLinkStyles);
+const GetInvolvedRouteLink = styled(RouteLink)(getInvolvedLinkStyles);
+
+interface GetInvolvedLinkProps {
+ href: string;
+ children: ReactNode;
+}
+
+const GetInvolvedLink: FunctionComponent = ({ href, children }) => {
+ const external = /^https?:\/\//.test(href);
+ const internalRoute = href.startsWith('/') && !href.startsWith('//');
+
+ if (internalRoute) {
+ return {children} ;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export interface GetInvolvedProps {
+ heading?: string;
+ cards?: HomeInvolvementCard[];
+}
+
+/** Consumer-configured "Get Involved" cards (contribute, sponsor, etc.). */
+export const GetInvolved: FunctionComponent = ({ heading, cards }) => {
+ if (!cards || cards.length === 0) {
+ return null;
+ }
+ return (
+
+
+ {heading ?? 'Get Involved'}
+
+
+ {cards.map(card => {
+ return (
+
+
+ svg': { fontSize: '1.125rem' }
+ }}>
+ {card.icon}
+
+ {card.title}
+
+
+ {card.description}
+
+ {card.label}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/webui/src/pages/home/hero-search.tsx b/webui/src/pages/home/hero-search.tsx
new file mode 100644
index 000000000..775289258
--- /dev/null
+++ b/webui/src/pages/home/hero-search.tsx
@@ -0,0 +1,301 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import {
+ ChangeEvent,
+ ComponentType,
+ FormEvent,
+ FunctionComponent,
+ useCallback,
+ useLayoutEffect,
+ useRef,
+ useState
+} from 'react';
+import { Box, ButtonBase, Typography } from '@mui/material';
+import { useLocation } from 'react-router-dom';
+import { flushSync } from 'react-dom';
+import { styled, alpha } from '@mui/material/styles';
+import { accentHover, focusOutline, focusRing, Section } from '../../components/page-primitives';
+import { useSearch, SEARCH_DEBOUNCE_MS } from '../../hooks/use-search';
+import { useSearchQuery } from '../../context/search/search-context';
+import { useSearchFocus } from '../../context/search/search-focus-context';
+import { useRegisterPageSearchBar } from '../../context/search/page-search-bar-context';
+import { useSignalEffect } from '../../hooks/use-signal-effect';
+import { useDebouncedCallback } from '../../hooks/use-debounced-callback';
+import { MONO_FONT } from '../../default/theme';
+
+const HeroSearchWrap = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.8125rem',
+ backgroundColor: theme.palette.surface2,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: '15px',
+ height: '3.875rem',
+ paddingLeft: '1.25rem',
+ paddingRight: '0.5rem',
+ [theme.breakpoints.down('sm')]: {
+ height: '3.375rem',
+ paddingLeft: '0.875rem',
+ gap: '0.625rem'
+ },
+ boxShadow: 'var(--shadow)',
+ transition: 'border-color 0.2s ease, box-shadow 0.3s ease',
+ '&:focus-within': focusRing(theme, `0 18px 70px -10px ${alpha(theme.palette.secondary.main, 0.45)}`)
+}));
+
+const HeroInput = styled('input')(({ theme }) => ({
+ flex: 1,
+ height: '100%',
+ border: 'none',
+ outline: 'none',
+ background: 'none',
+ color: theme.palette.text.primary,
+ fontSize: '1.0625rem',
+ fontFamily: MONO_FONT,
+ '&::placeholder': { color: theme.palette.text.disabled }
+}));
+
+const HeroSubmitButton = styled(ButtonBase)(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ height: '2.875rem',
+ padding: '0 1.375rem',
+ borderRadius: '11px',
+ overflow: 'hidden',
+ backgroundColor: theme.palette.secondary.main,
+ color: theme.palette.secondary.contrastText,
+ fontSize: '0.9375rem',
+ fontWeight: 600,
+ flexShrink: 0,
+ transition: 'background 0.14s',
+ [theme.breakpoints.down('sm')]: {
+ height: '2.5rem',
+ padding: '0 0.875rem',
+ borderRadius: `${theme.shape.borderRadius}px`
+ },
+ '&:hover': { backgroundColor: theme.palette.secondary.dark },
+ ...focusOutline(theme)
+}));
+
+const PopularChip = styled(ButtonBase)(({ theme }) => ({
+ backgroundColor: theme.palette.surface2,
+ border: `1px solid ${theme.palette.divider}`,
+ color: theme.palette.text.secondary,
+ fontSize: '0.8125rem',
+ fontWeight: 500,
+ padding: '0.375rem 0.8125rem',
+ borderRadius: '999px',
+ overflow: 'hidden',
+ fontFamily: MONO_FONT,
+ transition: 'border-color 0.14s, color 0.14s',
+ ...accentHover(theme),
+ ...focusOutline(theme)
+}));
+
+export interface HeroSearchProps {
+ /** Rendered above the search field (headline, tagline, …). */
+ searchHeader?: ComponentType;
+ /** Search terms offered as one-click chips below the field. */
+ popularSearches?: string[];
+}
+
+/**
+ * The hero search section. Registers itself as the page's search bar while in
+ * view, so the nav bar hides its own field. Owns a local copy of `query` so
+ * keystrokes only re-render the field, not the whole homepage; typing debounces
+ * `search`, submit and popular chips search immediately.
+ */
+export const HeroSearch: FunctionComponent = ({
+ searchHeader: SearchHeader,
+ popularSearches = []
+}) => {
+ const { query: contextQuery, setQuery } = useSearchQuery();
+ const { search } = useSearch();
+ const { searchFocusSignal, searchFocused } = useSearchFocus();
+ const [query, setLocalQuery] = useState('');
+ const heroInputRef = useRef(null);
+ const isActiveSearchBar = useRegisterPageSearchBar(heroInputRef);
+
+ // Focus the hero input on request (e.g. the '/' shortcut) — the nav field owns focus while the hero is out of view.
+ useSignalEffect(
+ searchFocusSignal,
+ useCallback(() => {
+ const el = heroInputRef.current;
+ if (!el || !isActiveSearchBar) {
+ return;
+ }
+ el.focus();
+ // Move cursor to end so the user can keep typing.
+ requestAnimationFrame(() => el.setSelectionRange(el.value.length, el.value.length));
+ }, [isActiveSearchBar])
+ );
+
+ // Reconcile the scroll swap: the field taking over adopts the shared draft and
+ // takes focus if its counterpart had it (the signal routes to the active bar).
+ const wasActiveSearchBar = useRef(isActiveSearchBar);
+ useLayoutEffect(() => {
+ if (wasActiveSearchBar.current === isActiveSearchBar) {
+ return;
+ }
+ wasActiveSearchBar.current = isActiveSearchBar;
+ if (isActiveSearchBar) {
+ setLocalQuery(contextQuery);
+ if (searchFocused) searchFocusSignal.emit();
+ } else {
+ setQuery(query);
+ if (document.activeElement === heroInputRef.current) searchFocusSignal.emit();
+ }
+ }, [isActiveSearchBar, searchFocused, contextQuery, query, setQuery, searchFocusSignal.emit]);
+
+ // Focus the search by default when the hero page is the app's landing page.
+ const { key: locationKey } = useLocation();
+ useLayoutEffect(() => {
+ if (locationKey === 'default') {
+ searchFocusSignal.emit();
+ }
+ }, [locationKey, searchFocusSignal.emit]);
+
+ // Wrap search calls with a view transition so the hero input morphs into the nav bar.
+ type ViewTransitionDocument = Document & {
+ startViewTransition?: (callback: () => void) => { finished: Promise };
+ };
+
+ const searchWithTransition = useCallback(
+ (q: string) => {
+ // Sync context immediately so the nav bar input value is ready before the
+ // navigation commits (the hero input morphs into the nav field mid-transition).
+ setQuery(q);
+ const shouldFocus = Boolean(q);
+ // flushSync inside the transition commits the navigation and the focus signal
+ // synchronously, so the nav field takes focus while the hero input is still
+ // focused — keeping the mobile keyboard open across the morph.
+ const go = () => {
+ flushSync(() => {
+ search({ query: q });
+ if (shouldFocus) searchFocusSignal.emit();
+ });
+ };
+ const doc = document as ViewTransitionDocument;
+ if (doc.startViewTransition) {
+ const transition = doc.startViewTransition(go);
+ // Re-issue the focus request once the transition settles as a fallback:
+ // the synchronous focus above can be interrupted by the morph, and by now
+ // the nav field is fully mounted and interactive.
+ if (shouldFocus) {
+ transition.finished.then(searchFocusSignal.emit).catch(() => undefined);
+ }
+ } else {
+ go();
+ }
+ },
+ [search, setQuery, searchFocusSignal.emit]
+ );
+
+ const debouncedSearch = useDebouncedCallback(searchWithTransition, SEARCH_DEBOUNCE_MS);
+
+ const handleInputChange = (e: ChangeEvent) => {
+ const val = e.target.value;
+ setLocalQuery(val);
+ if (val.trim()) {
+ debouncedSearch(val.trim());
+ } else {
+ // Clearing the field shouldn't navigate away from the home page.
+ debouncedSearch.cancel();
+ }
+ };
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ debouncedSearch.cancel();
+ if (query.trim()) searchWithTransition(query.trim());
+ };
+
+ return (
+
+ {SearchHeader && }
+
+ {/* The nav field claims 'vt-search' while the hero is unregistered — duplicate names abort transitions. */}
+
+
+ /
+
+
+
+
+
+
+
+
+ search
+
+
+
+
+ {popularSearches.length > 0 && (
+
+
+ Popular:
+
+ {popularSearches.map(chip => (
+ searchWithTransition(chip)} style={{ flexShrink: 0 }}>
+ {chip}
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/webui/src/pages/home/home-page.tsx b/webui/src/pages/home/home-page.tsx
new file mode 100644
index 000000000..3a1579912
--- /dev/null
+++ b/webui/src/pages/home/home-page.tsx
@@ -0,0 +1,58 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, useContext } from 'react';
+import { Box } from '@mui/material';
+import { Navigate, useSearchParams } from 'react-router-dom';
+import { MainContext } from '../../context';
+import { HomeSettings } from '../../page-settings';
+import { ExtensionListRoutes } from '../extension-list/extension-list-routes';
+import { addQuery } from '../../utils';
+import { HeroSearch } from './hero-search';
+import { BrowseCategories } from './browse-categories';
+import { CuratedSections } from './curated-sections';
+import { GetInvolved } from './get-involved';
+
+/** Landing page. Pre-redesign search URLs lived here (/?search=...), so redirect those to /search. */
+export const HomePage: FunctionComponent = () => {
+ const { pageSettings } = useContext(MainContext);
+ const [params] = useSearchParams();
+ if (['search', 'category', 'sortBy', 'sortOrder'].some(key => params.has(key))) {
+ const target = addQuery(ExtensionListRoutes.SEARCH, [
+ { key: 'q', value: params.get('search') ?? undefined },
+ { key: 'category', value: params.get('category') ?? undefined },
+ { key: 'sortBy', value: params.get('sortBy') ?? undefined },
+ { key: 'sortOrder', value: params.get('sortOrder') ?? undefined }
+ ]);
+ return ;
+ }
+ const home = pageSettings.elements.home;
+ if (typeof home === 'function') {
+ const CustomHome = home;
+ return ;
+ }
+ return ;
+};
+
+/** The built-in home page: hero search, category browser, curated extension rows and get-involved cards. */
+const HomeContent: FunctionComponent<{ home?: HomeSettings }> = ({ home }) => {
+ const { pageSettings } = useContext(MainContext);
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/webui/src/pages/home/use-home-data.ts b/webui/src/pages/home/use-home-data.ts
new file mode 100644
index 000000000..78ceecb69
--- /dev/null
+++ b/webui/src/pages/home/use-home-data.ts
@@ -0,0 +1,95 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { useContext, useMemo } from 'react';
+import { useQueries } from '@tanstack/react-query';
+import { MainContext } from '../../context';
+import { SearchEntry, SearchResult, SortOrder, isError } from '../../extension-registry-types';
+import { ExtensionCategory } from '../../extension-registry-types';
+import { useCategories } from '../../components/categories';
+import { HomeCuratedSection } from '../../page-settings';
+import { controllerFromSignal } from '../../query-client';
+
+/** Number of extensions fetched for each curated row. */
+export const CURATED_SIZE = 6;
+
+/** Categories shown in the home page grid. */
+const HOME_CATEGORIES = new Set([
+ 'AI',
+ 'Programming Languages',
+ 'Snippets',
+ 'Linters',
+ 'Themes',
+ 'Debuggers',
+ 'Formatters',
+ 'Keymaps',
+ 'SCM Providers',
+ 'Extension Packs',
+ 'Language Packs',
+ 'Data Science',
+ 'Machine Learning',
+ 'Visualization',
+ 'Notebooks'
+]);
+
+/** Curated rows shown when the consumer does not configure `home.curatedSections`. */
+export const DEFAULT_CURATED_SECTIONS: HomeCuratedSection[] = [
+ { title: 'Most downloaded', subtitle: 'The extensions developers rely on every day', sortBy: 'downloadCount' },
+ { title: 'Recently updated', subtitle: 'Fresh releases from publishers this week', sortBy: 'timestamp' }
+];
+
+export interface CuratedRow extends HomeCuratedSection {
+ extensions: SearchEntry[];
+ loading: boolean;
+}
+
+/** The browsable home category list: the registry's categories minus a few noisy ones. */
+export function useHomeCategories(): ExtensionCategory[] {
+ const allCategories = useCategories();
+ return useMemo(() => allCategories.filter(c => HOME_CATEGORIES.has(c)), [allCategories]);
+}
+
+/**
+ * Loads the curated extension rows, each fetched with its configured ordering.
+ * Rows start in a loading state and fill in as requests resolve; failed rows end
+ * up empty and are hidden by the consumer.
+ */
+export function useCuratedRows(curatedSections: HomeCuratedSection[]): CuratedRow[] {
+ const { service } = useContext(MainContext);
+
+ const results = useQueries({
+ queries: curatedSections.map(section => ({
+ queryKey: ['home-curated', section.sortBy],
+ queryFn: async ({ signal }: { signal: AbortSignal }): Promise => {
+ const result = await service.search(controllerFromSignal(signal), {
+ query: '',
+ category: '',
+ offset: 0,
+ size: CURATED_SIZE,
+ sortBy: section.sortBy,
+ sortOrder: 'desc' as SortOrder
+ });
+ if (isError(result)) {
+ throw result;
+ }
+ return (result as SearchResult).extensions;
+ }
+ }))
+ });
+
+ return curatedSections.map((section, idx) => ({
+ ...section,
+ extensions: results[idx].data ?? [],
+ loading: results[idx].isLoading
+ }));
+}
diff --git a/webui/src/pages/namespace-detail/namespace-detail.tsx b/webui/src/pages/namespace-detail/namespace-detail.tsx
index 4e7e75ee1..fec8f1c0c 100644
--- a/webui/src/pages/namespace-detail/namespace-detail.tsx
+++ b/webui/src/pages/namespace-detail/namespace-detail.tsx
@@ -9,14 +9,15 @@
********************************************************************************/
import { FunctionComponent, ReactNode, useContext, useEffect, useState, useRef } from 'react';
-import { Typography, Box, Container, Grid, Link, Divider } from '@mui/material';
+import { Typography, Box, Link, Divider } from '@mui/material';
import GitHubIcon from '@mui/icons-material/GitHub';
import LinkedInIcon from '@mui/icons-material/LinkedIn';
import TwitterIcon from '@mui/icons-material/Twitter';
import { useParams } from 'react-router-dom';
-import { ExtensionListItem } from '../extension-list/extension-list-item';
+import { ExtensionCard } from '../../components/extension-card';
import { MainContext } from '../../context';
import { DelayedLoadIndicator } from '../../components/delayed-load-indicator';
+import { Section } from '../../components/page-primitives';
import { NamespaceDetails, isError, UrlString } from '../../extension-registry-types';
export const NamespaceDetail: FunctionComponent = () => {
@@ -106,144 +107,126 @@ export const NamespaceDetail: FunctionComponent = () => {
};
const renderNamespaceDetails = (namespaceDetails: NamespaceDetails, truncateReadMore: boolean): ReactNode => {
+ const { website, supportLink, socialLinks } = namespaceDetails;
return (
<>
-
-
-
-
-
+
+
+
+
+
+
+ {namespaceDetails.displayName}
+
+ {namespaceDetails.description ? (
+
+
+ {namespaceDetails.description}
+
+ {showReadMore ? (
+
+ Read more
+
+ ) : null}
+
+ ) : null}
+ {website || supportLink ? (
-
-
-
-
- {namespaceDetails.displayName}
-
-
- {namespaceDetails.description ? (
-
-
- {namespaceDetails.description}
-
- {showReadMore ? (
-
- Read more
-
- ) : null}
-
- ) : null}
-
-
-
- {namespaceDetails.website ? (
-
-
- {displayLink(namespaceDetails.website)}
-
-
- ) : null}
- {namespaceDetails.website && namespaceDetails.supportLink ? (
-
-
-
- ) : null}
- {namespaceDetails.supportLink ? (
-
-
- {displayLink(namespaceDetails.supportLink)}
-
-
- ) : null}
-
-
-
-
- {namespaceDetails.socialLinks.linkedin ? (
-
-
-
-
-
- ) : null}
- {namespaceDetails.socialLinks.github ? (
-
-
-
-
-
- ) : null}
- {namespaceDetails.socialLinks.twitter ? (
-
-
-
-
-
- ) : null}
-
-
-
-
-
+ display: 'flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: '0.75rem',
+ mt: '1rem'
+ }}>
+ {website ? (
+
+ {displayLink(website)}
+
+ ) : null}
+ {website && supportLink ? : null}
+ {supportLink ? (
+
+ {displayLink(supportLink)}
+
+ ) : null}
+
+ ) : null}
+ {socialLinks.linkedin || socialLinks.github || socialLinks.twitter ? (
+
+ {socialLinks.linkedin ? (
+
+
+
+ ) : null}
+ {socialLinks.github ? (
+
+
+
+ ) : null}
+ {socialLinks.twitter ? (
+
+
+
+ ) : null}
+
+ ) : null}
+
-
+
{namespaceDetails.extensions ? (
-
-
+
+
{namespaceDetails.extensions.map((ext, idx) => (
-
))}
-
-
+
+
) : null}
>
);
diff --git a/webui/src/pages/search/search-header.tsx b/webui/src/pages/search/search-header.tsx
new file mode 100644
index 000000000..eb0356868
--- /dev/null
+++ b/webui/src/pages/search/search-header.tsx
@@ -0,0 +1,115 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent } from 'react';
+import { Box, IconButton, Select, MenuItem, Typography, SelectChangeEvent } from '@mui/material';
+import { SortBy, SortOrder } from '../../extension-registry-types';
+import { ExtensionCategory } from '../../extension-registry-types';
+import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
+import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
+
+export const SearchHeader: FunctionComponent = props => {
+ const { sortBy, sortOrder, onSortByChanged, onSortOrderChanged } = props;
+
+ const handleSortByChange = (event: SelectChangeEvent) => {
+ onSortByChanged(event.target.value as SortBy);
+ };
+
+ const toggleSortOrder = () => {
+ onSortOrderChanged(sortOrder === 'asc' ? 'desc' : 'asc');
+ };
+
+ const title = props.searchQuery ? `"${props.searchQuery}"` : props.category || 'All extensions';
+
+ return (
+
+
+
+ {title}
+
+
+ {props.resultNumber.toLocaleString()} extensions found
+
+
+
+
+ Sort by
+
+
+ Relevance
+ Date
+ Downloads
+ Rating
+
+
+ {sortOrder === 'asc' ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export interface SearchHeaderProps {
+ onSortByChanged: (sb: SortBy) => void;
+ onSortOrderChanged: (so: SortOrder) => void;
+ sortBy: SortBy;
+ sortOrder: SortOrder;
+ resultNumber: number;
+ searchQuery?: string;
+ category?: ExtensionCategory | '';
+}
diff --git a/webui/src/pages/search/search-page.tsx b/webui/src/pages/search/search-page.tsx
new file mode 100644
index 000000000..2407bcb6c
--- /dev/null
+++ b/webui/src/pages/search/search-page.tsx
@@ -0,0 +1,126 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+
+import { FunctionComponent, useState } from 'react';
+import { Box } from '@mui/material';
+import { SortBy, SortOrder } from '../../extension-registry-types';
+import { ExtensionCategory } from '../../extension-registry-types';
+import { ExtensionList } from '../../components/extension-list';
+import { CATEGORY_ICONS, DefaultCategoryIcon } from '../../components/categories';
+import { CategoryPill } from '../../components/category-pill';
+import { CategoryListItem } from '../../components/category-list-item';
+import { Eyebrow, Section } from '../../components/page-primitives';
+import { NAVBAR_HEIGHT } from '../../default/theme';
+import { useSearch } from '../../hooks/use-search';
+import { useCategories } from '../../components/categories';
+import { SearchHeader } from './search-header';
+
+export const SearchPage: FunctionComponent = () => {
+ const { filter, search } = useSearch();
+ const { query: searchQuery, category, sortBy, sortOrder } = filter;
+ const categories = useCategories();
+
+ const [resultNumber, setResultNumber] = useState(0);
+
+ return (
+
+ {/* Mobile category pills — outside the flex row so negative-margin bleed isn't clipped */}
+ {categories.length > 0 && (
+
+ {(['', ...categories] as Array).map(cat => {
+ const Icon = cat ? CATEGORY_ICONS[cat] : DefaultCategoryIcon;
+ return (
+ search({ category: cat })}
+ />
+ );
+ })}
+
+ )}
+
+
+ {/* Desktop categories sidebar */}
+
+ Categories
+ {(['', ...categories] as Array).map(cat => {
+ const Icon = cat ? CATEGORY_ICONS[cat] : DefaultCategoryIcon;
+ return (
+ search({ category: cat })}
+ />
+ );
+ })}
+
+
+ {/* Main content */}
+
+ search({ sortBy })}
+ onSortOrderChanged={(sortOrder: SortOrder) => search({ sortOrder })}
+ />
+
+
+
+
+ );
+};
diff --git a/webui/src/pages/user/avatar.tsx b/webui/src/pages/user/avatar.tsx
index 778fd3399..32f3e5925 100644
--- a/webui/src/pages/user/avatar.tsx
+++ b/webui/src/pages/user/avatar.tsx
@@ -9,87 +9,134 @@
********************************************************************************/
import { FunctionComponent, useContext, useRef, useState } from 'react';
-import { Avatar, Menu, Typography, MenuItem, Link, Divider, IconButton } from '@mui/material';
+import { Avatar, Box, IconButton, Link, Menu, MenuItem, Typography } from '@mui/material';
import { Link as RouteLink } from 'react-router-dom';
+import SettingsIcon from '@mui/icons-material/Settings';
+import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
+import LogoutIcon from '@mui/icons-material/Logout';
import { UserSettingsRoutes } from './user-settings-routes';
import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard-routes';
import { MainContext } from '../../context';
import { LogoutForm } from './logout';
+// Radius, font and min-height come from the MuiMenuItem theme override.
+const menuItemSx = {
+ py: '0.5rem',
+ px: '0.625rem',
+ gap: '0.625rem',
+ color: 'text.primary',
+ display: 'flex',
+ alignItems: 'center'
+} as const;
+
+const iconSx = { fontSize: '1.0625rem', color: 'text.disabled', flexShrink: 0 };
+
export const UserAvatar: FunctionComponent = () => {
- const [open, setOpen] = useState(false);
+ const [open, setOpen] = useState(false);
const context = useContext(MainContext);
- const avatarButton = useRef();
+ const anchorRef = useRef(null);
const logoutFormRef = useRef(null);
- const handleAvatarClick = () => {
- setOpen(!open);
- };
+ const user = context.user;
+ if (!user) return null;
- const handleClose = () => {
- setOpen(false);
- };
+ const initials = user.loginName.slice(0, 2).toUpperCase();
- const user = context.user;
- if (!user) {
- return null;
- }
return (
<>
(avatarButton.current = ref)}>
+ aria-label='User menu'
+ onClick={() => setOpen(true)}
+ sx={{ p: '0.3125rem' }}>
+ sx={{
+ width: 32,
+ height: 32,
+ bgcolor: 'accentSoft',
+ color: 'secondary.light',
+ fontSize: '0.75rem',
+ fontWeight: 700,
+ borderRadius: '8px'
+ }}>
+ {initials}
+
+ onClose={() => setOpen(false)}>
+ {/* User header */}
+
+
+ {initials}
+
+
+
+ Logged in as
+
+
+ setOpen(false)}>
+ {user.loginName}
+
+
+
+
-
- Logged in as
-
-
- {user.loginName}
-
+ component={RouteLink}
+ to={UserSettingsRoutes.PROFILE}
+ onClick={() => setOpen(false)}
+ sx={{ ...menuItemSx, textDecoration: 'none' }}>
+
+ Settings
-
-
-
- Settings
-
-
- {user.role && user.role === 'admin' ? (
-
-
- Admin Dashboard
-
+ {user.role === 'admin' && (
+ setOpen(false)}
+ sx={{ ...menuItemSx, textDecoration: 'none' }}>
+
+ Admin Dashboard
- ) : (
- ''
)}
- logoutFormRef.current?.submit()}>
+ logoutFormRef.current?.submit()} sx={menuItemSx}>
-
- Log Out
-
+
+ Log out
diff --git a/webui/src/pages/user/logout.tsx b/webui/src/pages/user/logout.tsx
index 11cffa8c5..dfca0ff81 100644
--- a/webui/src/pages/user/logout.tsx
+++ b/webui/src/pages/user/logout.tsx
@@ -9,19 +9,9 @@
* ****************************************************************************** */
import { PropsWithChildren, useContext, useEffect, useRef, useState, forwardRef } from 'react';
-import { Button } from '@mui/material';
-import { styled } from '@mui/material/styles';
import { isError, CsrfTokenJson } from '../../extension-registry-types';
import { MainContext } from '../../context';
-const LogoutButton = styled(Button)({
- cursor: 'pointer',
- textDecoration: 'none',
- border: 'none',
- background: 'none',
- padding: 0
-});
-
export const LogoutForm = forwardRef(({ children }, ref) => {
const [csrf, setCsrf] = useState();
const context = useContext(MainContext);
@@ -45,9 +35,9 @@ export const LogoutForm = forwardRef(({ chil
};
return (
-
);
});
diff --git a/webui/src/query-client.ts b/webui/src/query-client.ts
index 6dc089c78..33f332fbc 100644
--- a/webui/src/query-client.ts
+++ b/webui/src/query-client.ts
@@ -44,6 +44,17 @@ export const queryClient = new QueryClient({
}
});
+// Extension icons are cached as object URLs; revoke them when their query is
+// evicted from the cache so the underlying blobs are freed.
+queryClient.getQueryCache().subscribe(event => {
+ if (event.type === 'removed' && event.query.queryKey[0] === 'extension-icon') {
+ const url = event.query.state.data;
+ if (typeof url === 'string' && url.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ }
+ }
+});
+
/**
* Bridge between TanStack Query's `AbortSignal` and the `AbortController` that
* `ExtensionRegistryService` methods expect. Inside a `queryFn` we get a
diff --git a/webui/src/utils.ts b/webui/src/utils.ts
index 12bf5cc9a..6b10363f4 100644
--- a/webui/src/utils.ts
+++ b/webui/src/utils.ts
@@ -40,6 +40,16 @@ export function debounce(task: () => void, token: { timeout?: number }, delay: n
token.timeout = window.setTimeout(task, delay);
}
+const compactNumberFormat = new Intl.NumberFormat(undefined, {
+ notation: 'compact',
+ compactDisplay: 'short'
+} as Intl.NumberFormatOptions);
+
+/** Formats large counts compactly, e.g. 12345 -> "12K". */
+export function formatCompactNumber(value: number): string {
+ return compactNumberFormat.format(value);
+}
+
export function toLocalTime(timestamp?: string): string | undefined {
if (!timestamp) {
return undefined;
diff --git a/webui/yarn.lock b/webui/yarn.lock
index 524b8dfdc..3713b0563 100644
--- a/webui/yarn.lock
+++ b/webui/yarn.lock
@@ -794,6 +794,27 @@ __metadata:
languageName: node
linkType: hard
+"@fontsource-variable/geist-mono@npm:^5.2.8":
+ version: 5.2.8
+ resolution: "@fontsource-variable/geist-mono@npm:5.2.8"
+ checksum: 10/82c5afc603c0a20f115115490c5b2853fc4fb43af793fef811e71011700fa952d4d4bb382983138352ebca5c1c8381b07902350cbd3f70adee53eac088f83776
+ languageName: node
+ linkType: hard
+
+"@fontsource-variable/geist@npm:^5.2.9":
+ version: 5.2.9
+ resolution: "@fontsource-variable/geist@npm:5.2.9"
+ checksum: 10/cd249dc1e60f96277b9b33f6c4626385d1337a6d3c40b659bdf49e95eabff8a38083d2c579446bdbf77a104906a9b2ba15ad98e2177accdad192dc97d06bb980
+ languageName: node
+ linkType: hard
+
+"@fontsource/roboto@npm:^5.2.10":
+ version: 5.2.10
+ resolution: "@fontsource/roboto@npm:5.2.10"
+ checksum: 10/2fc9e9db66cfab68ff45b573cc2bf20fe8d7e962f09b6edd32d53681fdcd7bdedd65440012683491785583383a585bdbfb67903171159bf4c3520f2f0d26d92a
+ languageName: node
+ linkType: hard
+
"@humanfs/core@npm:^0.19.1":
version: 0.19.1
resolution: "@humanfs/core@npm:0.19.1"
@@ -5810,6 +5831,9 @@ __metadata:
"@emotion/styled": "npm:^11.11.0"
"@eslint/eslintrc": "npm:^3.3.3"
"@eslint/js": "npm:^9.39.0"
+ "@fontsource-variable/geist": "npm:^5.2.9"
+ "@fontsource-variable/geist-mono": "npm:^5.2.8"
+ "@fontsource/roboto": "npm:^5.2.10"
"@mdit/plugin-alert": "npm:^0.22.3"
"@mui/base": "npm:^5.0.0-beta.9"
"@mui/icons-material": "npm:^5.15.14"