diff --git a/webui/src/components/category-card.tsx b/webui/src/components/category-card.tsx index 05265ebf6..8b2fe3d22 100644 --- a/webui/src/components/category-card.tsx +++ b/webui/src/components/category-card.tsx @@ -46,7 +46,8 @@ export const CategoryCard: FunctionComponent = ({ label, icon sx={{ width: '1.875rem', height: '1.875rem', - borderRadius: '8px', + // sx multiplies by theme.shape.borderRadius + borderRadius: 1, bgcolor: 'accentSoft', color: 'secondary.main', display: 'flex', diff --git a/webui/src/components/category-pill.tsx b/webui/src/components/category-pill.tsx index bf260e67f..61802996d 100644 --- a/webui/src/components/category-pill.tsx +++ b/webui/src/components/category-pill.tsx @@ -30,7 +30,7 @@ const Root = styled(ButtonBase, { fontSize: '0.8125rem', fontWeight: isSelected ? 600 : 500, padding: '0.4375rem 0.8125rem', - borderRadius: '999px', + borderRadius: theme.shape.borderRadiusPill, whiteSpace: 'nowrap', fontFamily: 'inherit', transition: 'border-color 0.14s, color 0.14s', diff --git a/webui/src/components/extension-card.tsx b/webui/src/components/extension-card.tsx index 476f72960..79dab0b76 100644 --- a/webui/src/components/extension-card.tsx +++ b/webui/src/components/extension-card.tsx @@ -16,6 +16,7 @@ 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 VerifiedIcon from '@mui/icons-material/Verified'; import { ExtensionDetailRoutes } from '../pages/extension-detail/extension-detail-routes'; import { SearchEntry } from '../extension-registry-types'; import { ExtensionIcon } from './extension/extension-icon'; @@ -26,15 +27,17 @@ 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. +// A size container so tight cards can shed the footer's review count. const cardLayout = (theme: Theme): CSSObject => ({ ...cardSurface(theme), - padding: '1.375rem 1rem', - [theme.breakpoints.down('sm')]: { padding: '0.875rem 0.625rem' }, + containerType: 'inline-size', + padding: '1rem 0.875rem', + [theme.breakpoints.down('sm')]: { padding: '0.75rem 0.625rem' }, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', - minHeight: '12.875rem' + minHeight: '13rem' }); const CardRoot = styled(Paper)(({ theme }) => ({ @@ -54,14 +57,15 @@ 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 = () => ( <> - - - - + + + + + - + - + ( borderColor: 'border2', display: 'flex', alignItems: 'center', - fontSize: { xs: '0.875rem', sm: '1.25rem' } + gap: '0.0625rem', + fontSize: { xs: '0.8125rem', sm: '0.875rem' } }}> @@ -107,6 +112,7 @@ export const ExtensionCard = memo( ) { const title = extension?.displayName ?? extension?.name; const downloadCount = extension ? formatCompactNumber(extension.downloadCount ?? 0) : undefined; + const reviewCount = extension?.reviewCount ?? 0; // One Fade over both states so it runs once and carries through the skeleton → card swap. return ( @@ -130,7 +136,7 @@ export const ExtensionCard = memo( sx={{ width: 54, height: 54, - mb: '0.875rem' + mb: '0.75rem' }}> + {title} + + {/* Fixed two-line box so the meta rows align across cards with short descriptions. */} + - {title} + {extension.description} :first-of-type:hover': { flexShrink: 0, maxWidth: 'none' }, + '& > :first-of-type:hover + *': { minWidth: 0, flexShrink: 1 }, + '& > :last-of-type:hover': { flexShrink: 0, maxWidth: 'none' }, + '&:has(> :last-of-type:hover) > :first-of-type': { + flexShrink: 1, + minWidth: 0 + } }}> - - {extension.namespace} - + + {extension.namespace} + + {extension.verified && ( + + + + )} + {extension.version} + {extension.deprecated && ( + + deprecated + + )} - + + {reviewCount > 0 && ( + + ({formatCompactNumber(reviewCount)}) + + )} {downloadCount !== '0' && ( = ({ filter, o }} hasMore={hasNextPage && !isFetchingNextPage} threshold={200}> - - {cards} - + {cards} ); }; diff --git a/webui/src/components/extension-searchfield.tsx b/webui/src/components/extension-searchfield.tsx index eaa5a0de0..20d41cbe1 100644 --- a/webui/src/components/extension-searchfield.tsx +++ b/webui/src/components/extension-searchfield.tsx @@ -12,11 +12,11 @@ ********************************************************************************/ 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 SearchIcon from '@mui/icons-material/Search'; +import ClearIcon from '@mui/icons-material/Close'; import { focusRing } from './page-primitives'; interface ExtensionSearchfieldProps { @@ -40,8 +40,8 @@ const SearchWrap = styled(Box, { gap: '0.625rem', border: hasError ? '2px solid' : '1px solid', borderColor: hasError ? theme.palette.error.main : theme.palette.divider, - borderRadius: '11px', - height: '2.8125rem', + borderRadius: theme.shape.borderRadiusCard, + height: '2.8rem', padding: '0 0.8125rem', backgroundColor: alpha(theme.palette.surface2, 0.7), backdropFilter: 'blur(2px)', @@ -154,8 +154,9 @@ export const ExtensionSearchfield = forwardRef( sx={{ bgcolor: 'secondary.main', color: 'secondary.contrastText', - borderRadius: '8px', - p: '0.5rem', + // sx multiplies by theme.shape.borderRadius + borderRadius: 1, + p: '0.375rem', flexShrink: 0, transition: 'background 0.14s', '&:hover': { bgcolor: 'secondary.dark' } diff --git a/webui/src/components/kbd-key.tsx b/webui/src/components/kbd-key.tsx index c4b92da35..5312ce1a7 100644 --- a/webui/src/components/kbd-key.tsx +++ b/webui/src/components/kbd-key.tsx @@ -30,7 +30,7 @@ export const KbdKey: FunctionComponent = ({ children }) => ( pb: '0.1875rem', minWidth: '1.125rem', textAlign: 'center', - borderRadius: '4px', + borderRadius: 1, // All colors mix from the inherited text color, so the chip tints with // its context instead of being a fixed gray. color: 'color-mix(in srgb, currentcolor 62%, transparent)', diff --git a/webui/src/components/page-container.tsx b/webui/src/components/page-container.tsx index 26fbda2b1..a761558a9 100644 --- a/webui/src/components/page-container.tsx +++ b/webui/src/components/page-container.tsx @@ -39,7 +39,7 @@ export const PageContainer = forwardRef(func disableGutters={fluid} sx={[ { - pt: flushTop ? 0 : { xs: '2.75rem', sm: '4.875rem' }, + pt: flushTop ? 0 : { xs: '1.75rem', sm: '2.5rem' }, pb: flushBottom ? 0 : { xs: '2.5rem', sm: '4rem' } }, ...(Array.isArray(sx) ? sx : [sx]) diff --git a/webui/src/components/page-primitives.tsx b/webui/src/components/page-primitives.tsx index d2a31cbd8..efc6764e8 100644 --- a/webui/src/components/page-primitives.tsx +++ b/webui/src/components/page-primitives.tsx @@ -24,6 +24,24 @@ export const SectionStack = styled(Box)(({ theme }) => ({ } })); +/** Full-bleed hairline dividing stacked page sections, VS Code workbench style. */ +export const SectionSeparator = styled(Box)(({ theme }) => ({ + borderTop: `1px solid ${theme.palette.border2}` +})); + +/** Extension-card grid shared by the listings; column count falls out of available + * width. Sparse grids keep auto-fill's empty tracks so few results stay card-sized; + * with 7+ items (a curated row or a full results page) auto-fit collapses leftover + * tracks so rows stretch to fill the width. */ +export const ExtensionGrid = styled(Box)({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(145px, 1fr))', + gap: '0.75rem', + '&:has(> :nth-of-type(7))': { + gridTemplateColumns: 'repeat(auto-fit, minmax(145px, 1fr))' + } +}); + /** Small uppercase label used to head sections, columns and sidebars. */ export const Eyebrow = styled(Typography)(({ theme }) => ({ fontSize: '0.75rem', diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index aa742baef..9033b9710 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -9,14 +9,8 @@ ********************************************************************************/ import { FunctionComponent, PropsWithChildren, useContext, useRef, useState } from 'react'; -import { Typography, MenuItem, Link, Button, IconButton, Avatar, Menu } from '@mui/material'; +import { Avatar, Button, IconButton, Link, Menu, MenuItem, Typography } 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 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 { alpha, styled, Theme } from '@mui/material/styles'; @@ -24,9 +18,15 @@ import { MainContext } from '../context'; import { KbdKey } from '../components/kbd-key'; import { useShortcut } from '../hooks/use-shortcut'; import { focusOutline } from '../components/page-primitives'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import GitHubIcon from '@mui/icons-material/GitHub'; import SettingsIcon from '@mui/icons-material/Settings'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import LogoutIcon from '@mui/icons-material/Logout'; +import AccountBoxIcon from '@mui/icons-material/AccountBox'; +import PublishIcon from '@mui/icons-material/Publish'; +import MenuBookIcon from '@mui/icons-material/MenuBook'; +import InfoIcon from '@mui/icons-material/Info'; import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-dashboard-routes'; import { LogoutForm } from '../pages/user/logout'; import { LoginComponent } from './login'; @@ -243,11 +243,22 @@ export const DefaultMenuContent: FunctionComponent = () => { { - // The default `action.active` gray disappears on tinted chromes. + // Inherited color + opacity (not a palette gray) so the dimming + // follows the nav's content color over tinted chromes, like headerItem. + const loginButtonSx = { + opacity: 0.78, + transition: 'opacity 0.14s', + '&:hover': { opacity: 1 } + }; if (href) { return ( - - + + ); } else { @@ -256,8 +267,9 @@ export const DefaultMenuContent: FunctionComponent = () => { onClick={onClick} color='inherit' title='Log In' - aria-label='Log In'> - + aria-label='Log In' + sx={loginButtonSx}> + ); } diff --git a/webui/src/default/page-settings.tsx b/webui/src/default/page-settings.tsx index d7753ee99..4e9f2f84d 100644 --- a/webui/src/default/page-settings.tsx +++ b/webui/src/default/page-settings.tsx @@ -102,7 +102,7 @@ export default function createPageSettings(prefersDarkMode: boolean, serverUrl: }; const searchHeader: FunctionComponent = () => ( - + `${theme.shape.borderRadiusPill}px`, bgcolor: 'accentSoft', color: 'secondary.light', fontSize: '0.75rem', - fontWeight: 600, - mb: 3 + fontWeight: 500, + mb: 2 }}> Find the right extension,
diff --git a/webui/src/default/theme.tsx b/webui/src/default/theme.tsx index eff2084e5..320a6c06f 100644 --- a/webui/src/default/theme.tsx +++ b/webui/src/default/theme.tsx @@ -100,6 +100,7 @@ declare module '@mui/material/styles/createPalette' { declare module '@mui/system/createTheme/shape' { interface Shape { borderRadiusCard: number; + borderRadiusPill: number; } } @@ -107,11 +108,18 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { const dark = themeType === 'dark'; return createTheme({ typography: { - fontFamily: "'Geist', 'Roboto', system-ui, -apple-system, sans-serif" + fontFamily: "'Geist', 'Roboto', system-ui, -apple-system, sans-serif", + button: { + textTransform: 'none', + fontWeight: 400, + letterSpacing: 0 + } }, + // Tuned to VS Code's newer "islands" look: softly rounded controls, rounder panels. shape: { - borderRadius: 9, - borderRadiusCard: 14 + borderRadius: 4, + borderRadiusCard: 6, + borderRadiusPill: 6 }, mixins: { toolbar: { minHeight: NAVBAR_HEIGHT } @@ -210,10 +218,11 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { } } }, + // VS Code buttons are flat and compact (~24px page actions), with corners + // a touch softer than inputs; outlined trims 1px per side so the border + // doesn't add height. MuiButton: { - styleOverrides: { - root: { textTransform: 'none' } - } + defaultProps: { disableElevation: true } }, // 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 @@ -247,19 +256,20 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { styleOverrides: { indicator: ({ theme }) => ({ backgroundColor: theme.palette.secondary.main, - height: '2px' + height: '1px' }) } }, + // VS Code tabs: 13px, regular weight, selection shown by color alone. MuiTab: { styleOverrides: { root: ({ theme }) => ({ - fontSize: '0.875rem', - fontWeight: 600, + fontSize: '0.8125rem', + fontWeight: 400, textTransform: 'none', color: theme.palette.text.disabled, - minHeight: '3.25rem', - padding: '0.9375rem 1rem', + minHeight: '2.5rem', + padding: '0.625rem 0.875rem', '&.Mui-selected': { color: theme.palette.text.primary } }) } @@ -268,26 +278,21 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { MuiMenu: { styleOverrides: { paper: { marginTop: '0.375rem' }, - list: { padding: '0.375rem' } + list: { padding: '0.25rem' } } }, MuiMenuItem: { styleOverrides: { root: ({ theme }) => ({ borderRadius: theme.shape.borderRadius, - fontSize: '0.875rem', - fontWeight: 500, - minHeight: '2.25rem' + fontSize: '0.8125rem', + fontWeight: 400, + minHeight: '2rem' }) } }, MuiTypography: { styleOverrides: { - button: { - textTransform: 'none', - fontWeight: 500, - letterSpacing: 0 - }, overline: { textTransform: 'none', letterSpacing: 0, diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 4e0243ee4..24a3f1dd2 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -44,6 +44,7 @@ export interface SearchEntry { namespace: string; version: string; timestamp?: TimestampString; + verified?: boolean; allVersions: { url: UrlString; // key: file type, value: url diff --git a/webui/src/layout/app-layout.tsx b/webui/src/layout/app-layout.tsx index 9a6fc1048..10bba3734 100644 --- a/webui/src/layout/app-layout.tsx +++ b/webui/src/layout/app-layout.tsx @@ -104,10 +104,14 @@ const AppLayoutContent: FunctionComponent = props => { ) : null} {/* Mobile: fill the viewport so the (screen-tall) footer stays below the fold. - Desktop: flexGrow alone sticks the footer to the viewport bottom. */} + Desktop: flexGrow alone sticks the footer to the viewport bottom. A flex + column so pages can opt into stretching over the leftover space + (e.g. the search page runs its sidebar seam down to the footer). */} . pb: legacyFooterHeight ? `${legacyFooterHeight + 24}px` : 0 diff --git a/webui/src/main.css b/webui/src/main.css index bb4a0ae44..47d3199b1 100644 --- a/webui/src/main.css +++ b/webui/src/main.css @@ -17,6 +17,12 @@ body { height: 100%; } +/* Full-bleed rules (e.g. the search header divider) may overshoot by the classic + scrollbar's half-width; clip instead of growing a horizontal scrollbar. */ +html { + overflow-x: clip; +} + img { object-fit: contain; } diff --git a/webui/src/pages/extension-detail/extension-detail.tsx b/webui/src/pages/extension-detail/extension-detail.tsx index be40ddc5d..5707da165 100644 --- a/webui/src/pages/extension-detail/extension-detail.tsx +++ b/webui/src/pages/extension-detail/extension-detail.tsx @@ -26,9 +26,6 @@ import { } from '@mui/material'; import { alpha, styled } from '@mui/material/styles'; import { Link as RouteLink, Route, Routes, useNavigate, useParams } from 'react-router-dom'; -import SaveAltIcon from '@mui/icons-material/SaveAlt'; -import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; -import WarningIcon from '@mui/icons-material/Warning'; import { MainContext } from '../../context'; import { createRoute, formatCompactNumber } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; @@ -50,6 +47,9 @@ import { NAVBAR_HEIGHT, NAVBAR_HEIGHT_PX } from '../../default/theme'; import { useSetExtensionTint } from '../../context/extension-tint-context'; import { accentHover, focusOutline } from '../../components/page-primitives'; import { PageContainer } from '../../components/page-container'; +import SaveAltIcon from '@mui/icons-material/SaveAlt'; +import WarningIcon from '@mui/icons-material/Warning'; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; // Category-pill look for the sticky tabs, floating over the nav bar's blur fan; // the translucent fill matches the nav search field's treatment. @@ -57,7 +57,7 @@ const PillTab = styled(Tab)(({ theme }) => ({ minHeight: 0, minWidth: 0, padding: '0.4375rem 0.8125rem', - borderRadius: '999px', + borderRadius: theme.shape.borderRadiusPill, border: `1px solid ${theme.palette.divider}`, backgroundColor: alpha(theme.palette.surface2, 0.7), backdropFilter: 'blur(2px) saturate(1.8)', diff --git a/webui/src/pages/extension-detail/extension-rating-stars.tsx b/webui/src/pages/extension-detail/extension-rating-stars.tsx index 77ae8e56e..7ceb641af 100644 --- a/webui/src/pages/extension-detail/extension-rating-stars.tsx +++ b/webui/src/pages/extension-detail/extension-rating-stars.tsx @@ -26,9 +26,12 @@ export const ExtensionRatingStars: FunctionComponent return ; } if (i > starsNumber && i - 1 < starsNumber) { + // inline-flex + explicit inset pin the half star exactly over the gray + // one; an absolute box without offsets sits at its baseline-relative + // static position and drifts as line-height changes. return ( - - + + ); diff --git a/webui/src/pages/home/curated-sections.tsx b/webui/src/pages/home/curated-sections.tsx index f65bc4a1d..ac72a414e 100644 --- a/webui/src/pages/home/curated-sections.tsx +++ b/webui/src/pages/home/curated-sections.tsx @@ -14,6 +14,7 @@ import { FunctionComponent } from 'react'; import { Box, Container, Typography } from '@mui/material'; import { ExtensionCard } from '../../components/extension-card'; +import { ExtensionGrid } 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'; @@ -43,7 +44,7 @@ export const CuratedSections: FunctionComponent = props => display: 'flex', alignItems: 'center', justifyContent: 'space-between', - mb: '1.125rem' + mb: '0.875rem' }}> = props => 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) => ( = props => fadeDelayMs={(idx % CURATED_SIZE) * 200} /> ))} - + ); })} diff --git a/webui/src/pages/home/get-involved.tsx b/webui/src/pages/home/get-involved.tsx index c6f451017..f36dd3a48 100644 --- a/webui/src/pages/home/get-involved.tsx +++ b/webui/src/pages/home/get-involved.tsx @@ -22,8 +22,8 @@ import { 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', + borderRadius: theme.shape.borderRadiusCard, + padding: '1.25rem', display: 'flex', flexDirection: 'column' })); @@ -92,7 +92,8 @@ export const GetInvolved: FunctionComponent = ({ heading, card sx={{ width: '2.125rem', height: '2.125rem', - borderRadius: '9px', + // sx multiplies by theme.shape.borderRadius + borderRadius: 1, bgcolor: 'accentSoft', color: 'secondary.light', display: 'flex', diff --git a/webui/src/pages/home/hero-search.tsx b/webui/src/pages/home/hero-search.tsx index e07015f8e..1d09459c1 100644 --- a/webui/src/pages/home/hero-search.tsx +++ b/webui/src/pages/home/hero-search.tsx @@ -40,12 +40,12 @@ const HeroSearchWrap = styled(Box)(({ theme }) => ({ gap: '0.8125rem', backgroundColor: theme.palette.surface2, border: `1px solid ${theme.palette.divider}`, - borderRadius: '15px', - height: '3.875rem', - paddingLeft: '1.25rem', - paddingRight: '0.5rem', + borderRadius: theme.shape.borderRadiusCard, + height: '3.25rem', + paddingLeft: '1rem', + paddingRight: '0.25rem', [theme.breakpoints.down('sm')]: { - height: '3.375rem', + height: '3rem', paddingLeft: '0.875rem', gap: '0.625rem' }, @@ -61,7 +61,7 @@ const HeroInput = styled('input')(({ theme }) => ({ outline: 'none', background: 'none', color: theme.palette.text.primary, - fontSize: '1.0625rem', + fontSize: '0.9375rem', fontFamily: MONO_FONT, '&::placeholder': { color: theme.palette.text.disabled } })); @@ -70,20 +70,19 @@ const HeroSubmitButton = styled(ButtonBase)(({ theme }) => ({ display: 'flex', alignItems: 'center', gap: '0.5rem', - height: '2.875rem', - padding: '0 1.375rem', - borderRadius: '11px', + height: '2.75rem', + padding: '0 1rem', + borderRadius: theme.shape.borderRadius, overflow: 'hidden', backgroundColor: theme.palette.secondary.main, color: theme.palette.secondary.contrastText, - fontSize: '0.9375rem', - fontWeight: 600, + fontSize: '0.8125rem', + fontWeight: 400, flexShrink: 0, transition: 'background 0.14s', [theme.breakpoints.down('sm')]: { height: '2.5rem', - padding: '0 0.875rem', - borderRadius: `${theme.shape.borderRadius}px` + padding: '0 0.875rem' }, '&:hover': { backgroundColor: theme.palette.secondary.dark }, ...focusOutline(theme) @@ -94,9 +93,9 @@ const PopularChip = styled(ButtonBase)(({ theme }) => ({ border: `1px solid ${theme.palette.divider}`, color: theme.palette.text.secondary, fontSize: '0.8125rem', - fontWeight: 500, - padding: '0.375rem 0.8125rem', - borderRadius: '999px', + fontWeight: 400, + padding: '0.3125rem 0.625rem', + borderRadius: theme.shape.borderRadiusPill, overflow: 'hidden', fontFamily: MONO_FONT, transition: 'border-color 0.14s, color 0.14s', @@ -271,7 +270,7 @@ export const HeroSearch: FunctionComponent = ({ justifyContent: { xs: 'flex-start', sm: 'center' }, flexWrap: { xs: 'nowrap', sm: 'wrap' }, overflowX: { xs: 'auto', sm: 'visible' }, - mt: '1.125rem', + mt: '0.875rem', mx: { xs: '-1rem', sm: 0 }, px: { xs: '1rem', sm: 0 }, pb: { xs: '0.25rem', sm: 0 }, diff --git a/webui/src/pages/home/home-page.tsx b/webui/src/pages/home/home-page.tsx index 5eabd215a..b6bde4aa0 100644 --- a/webui/src/pages/home/home-page.tsx +++ b/webui/src/pages/home/home-page.tsx @@ -14,7 +14,7 @@ import { FunctionComponent, useContext } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; import { PageContainer } from '../../components/page-container'; -import { SectionStack } from '../../components/page-primitives'; +import { SectionSeparator, SectionStack } from '../../components/page-primitives'; import { MainContext } from '../../context'; import { HomeSettings } from '../../page-settings'; import { ExtensionListRoutes } from '../extension-list/extension-list-routes'; @@ -52,8 +52,9 @@ const HomeContent: FunctionComponent<{ home?: HomeSettings }> = ({ home }) => { - + + diff --git a/webui/src/pages/home/use-home-data.ts b/webui/src/pages/home/use-home-data.ts index 78ceecb69..435dc441d 100644 --- a/webui/src/pages/home/use-home-data.ts +++ b/webui/src/pages/home/use-home-data.ts @@ -21,7 +21,7 @@ import { HomeCuratedSection } from '../../page-settings'; import { controllerFromSignal } from '../../query-client'; /** Number of extensions fetched for each curated row. */ -export const CURATED_SIZE = 6; +export const CURATED_SIZE = 7; /** Categories shown in the home page grid. */ const HOME_CATEGORIES = new Set([ @@ -44,6 +44,7 @@ const HOME_CATEGORIES = new Set([ /** Curated rows shown when the consumer does not configure `home.curatedSections`. */ export const DEFAULT_CURATED_SECTIONS: HomeCuratedSection[] = [ + { title: 'Featured', subtitle: 'Top picks ranked by relevance', sortBy: 'relevance' }, { 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' } ]; diff --git a/webui/src/pages/namespace-detail/namespace-detail.tsx b/webui/src/pages/namespace-detail/namespace-detail.tsx index bd2c095ed..555fc91eb 100644 --- a/webui/src/pages/namespace-detail/namespace-detail.tsx +++ b/webui/src/pages/namespace-detail/namespace-detail.tsx @@ -15,6 +15,7 @@ import LinkedInIcon from '@mui/icons-material/LinkedIn'; import TwitterIcon from '@mui/icons-material/Twitter'; import { useParams } from 'react-router-dom'; import { ExtensionCard } from '../../components/extension-card'; +import { ExtensionGrid } from '../../components/page-primitives'; import { MainContext } from '../../context'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { NamespaceDetails, isError, UrlString } from '../../extension-registry-types'; @@ -208,15 +209,7 @@ export const NamespaceDetail: FunctionComponent = () => { {namespaceDetails.extensions ? ( - + {namespaceDetails.extensions.map((ext, idx) => ( { key={`${ext.namespace}.${ext.name}`} /> ))} - + ) : null} diff --git a/webui/src/pages/search/search-header.tsx b/webui/src/pages/search/search-header.tsx index eb0356868..2aa5de40d 100644 --- a/webui/src/pages/search/search-header.tsx +++ b/webui/src/pages/search/search-header.tsx @@ -15,9 +15,14 @@ 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 { Theme } from '@mui/material/styles'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +// Distance from the content column's right edge to the viewport edge: the centered +// PageContainer's outer gap (its cap is the xl breakpoint) plus its 1.5rem gutter (sm and up). +const edgeBleed = (theme: Theme) => `calc((100vw - min(100vw, ${theme.breakpoints.values.xl}px)) / 2 + 1.5rem)`; + export const SearchHeader: FunctionComponent = props => { const { sortBy, sortOrder, onSortByChanged, onSortOrderChanged } = props; @@ -33,14 +38,23 @@ export const SearchHeader: FunctionComponent = props => { return ( ({ pt: '1.75rem', - pb: '1rem', + pb: '0.875rem', + mb: '1rem', + ml: { md: '-1.25rem' }, + pl: { md: '1.25rem' }, + mr: { xs: '-1rem', sm: `calc(${edgeBleed(theme)} * -1)` }, + pr: { xs: '1rem', sm: edgeBleed(theme) }, + borderBottom: '1px solid', + borderColor: 'border2', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' - }}> + })}> = props => { color: 'text.primary', height: '1.875rem', bgcolor: 'background.paper', - borderRadius: '8px', + borderRadius: 1, '& .MuiSelect-select': { py: '0.25rem', pl: '0.625rem' }, '& .MuiSelect-icon': { color: 'text.disabled', fontSize: '1.125rem' } }}> @@ -88,7 +102,7 @@ export const SearchHeader: FunctionComponent = props => { aria-label={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'} sx={{ color: 'text.disabled', - borderRadius: '6px', + borderRadius: 1, p: '0.1875rem', transition: 'color 0.14s', '&:hover': { color: 'secondary.light', bgcolor: 'transparent' } diff --git a/webui/src/pages/search/search-page.tsx b/webui/src/pages/search/search-page.tsx index a4ed2c870..ff7e37ce8 100644 --- a/webui/src/pages/search/search-page.tsx +++ b/webui/src/pages/search/search-page.tsx @@ -34,8 +34,10 @@ export const SearchPage: FunctionComponent = () => { const [resultNumber, setResultNumber] = useState(0); return ( - // flushTop: hug the nav; keep the default footer gap. - + // flushTop hugs the nav; flushBottom moves the footer gap into the results + // column, so its seam runs unbroken into the footer divider. Growing into the + // layout's leftover space keeps the seam continuous on short result sets too. + {/* Mobile category pills — outside the flex row so negative-margin bleed isn't clipped */} {categories.length > 0 && ( { )} - + {/* Desktop categories sidebar */} { })} - {/* Main content */} - + {/* Main content, split from the sidebar like VS Code's sidebar/editor seam. + The color rides in the shorthand: a separate borderColor would be + overridden by this media-queried shorthand resetting it. */} + ({ + flex: 1, + minWidth: 0, + borderLeft: { xs: 'none', md: `1px solid ${theme.palette.border2}` }, + pl: { md: '1.25rem' }, + // The footer gap PageContainer would normally own (flushBottom). + pb: { xs: '2.5rem', sm: '4rem' } + })}> { onSortOrderChanged={(sortOrder: SortOrder) => search({ sortOrder })} /> diff --git a/webui/src/pages/user/avatar.tsx b/webui/src/pages/user/avatar.tsx index 32f3e5925..68c33c2b8 100644 --- a/webui/src/pages/user/avatar.tsx +++ b/webui/src/pages/user/avatar.tsx @@ -60,7 +60,7 @@ export const UserAvatar: FunctionComponent = () => { color: 'secondary.light', fontSize: '0.75rem', fontWeight: 700, - borderRadius: '8px' + borderRadius: 1 }}> {initials} @@ -82,7 +82,7 @@ export const UserAvatar: FunctionComponent = () => { color: 'secondary.light', fontSize: '0.9375rem', fontWeight: 700, - borderRadius: '10px', + borderRadius: 1, flexShrink: 0 }}> {initials} diff --git a/webui/src/utils.ts b/webui/src/utils.ts index 6b10363f4..45b22fc32 100644 --- a/webui/src/utils.ts +++ b/webui/src/utils.ts @@ -123,6 +123,27 @@ export function toRelativeTime(timestamp?: string, isFutureTime: boolean = false } } +/** Short form of {@link toRelativeTime} for tight layouts: "5m", "3h", "2d", "4mo", "1y". */ +export function toCompactRelativeTime(timestamp?: string): string | undefined { + if (!timestamp) { + return undefined; + } + const elapsed = Date.now() - new Date(timestamp).getTime(); + if (elapsed < msPerMinute) { + return 'now'; + } else if (elapsed < msPerHour) { + return `${Math.round(elapsed / msPerMinute)}m`; + } else if (elapsed < msPerDay) { + return `${Math.round(elapsed / msPerHour)}h`; + } else if (elapsed < msPerMonth) { + return `${Math.round(elapsed / msPerDay)}d`; + } else if (elapsed < msPerYear) { + return `${Math.round(elapsed / msPerMonth)}mo`; + } else { + return `${Math.round(elapsed / msPerYear)}y`; + } +} + export function handleError(err?: Error | Partial): string { if (err) { if (err instanceof Error && err.name === 'AbortError') {