diff --git a/apps/docsite/src/__tests__/static-shell.test.ts b/apps/docsite/src/__tests__/static-shell.test.ts index d6ae12c7fbd..0db7ab25480 100644 --- a/apps/docsite/src/__tests__/static-shell.test.ts +++ b/apps/docsite/src/__tests__/static-shell.test.ts @@ -42,12 +42,22 @@ describe('docsite static shell', () => { ); }); - it('gives the query-dependent theme explorer a named fallback', () => { + it('reuses the same theme heading in fallback and resolved layouts', () => { const themes = source('app/(site)/themes/page.tsx'); + const themePage = source('components/ThemePackagePage.tsx'); + const heading = source('components/ThemeHeading.tsx'); expect(themes).toContain('const params = await searchParams'); expect(themes).toContain('fallback={}'); expect(themes).not.toMatch(/]*fallback=\{null\}[\s\S]*?>/); + expect(themePage).toContain("import {ThemeHeading} from './ThemeHeading'"); + expect(themes).toContain( + "import {ThemeHeading} from '../../../components/ThemeHeading'", + ); + expect(themePage.match(/ { diff --git a/apps/docsite/src/app/(site)/themes/page.tsx b/apps/docsite/src/app/(site)/themes/page.tsx index 5fa707c29a1..701ae9091cf 100644 --- a/apps/docsite/src/app/(site)/themes/page.tsx +++ b/apps/docsite/src/app/(site)/themes/page.tsx @@ -20,10 +20,14 @@ import * as stylex from '@stylexjs/stylex'; import {Suspense} from 'react'; import {notFound} from 'next/navigation'; import {Section} from '@astryxdesign/core/Section'; +import {VStack} from '@astryxdesign/core/Layout'; import {Skeleton} from '@astryxdesign/core/Skeleton'; +import {Card} from '@astryxdesign/core/Card'; +import {Divider} from '@astryxdesign/core/Divider'; import {packages} from '../../../generated/packageRegistry'; import {themeObjects} from '../../../generated/themeRegistry'; import {ThemePackagePage} from '../../../components/ThemePackagePage'; +import {ThemeHeading} from '../../../components/ThemeHeading'; import {pageMetadata} from '../../../lib/pageMetadata'; // Static canonical metadata for /themes. The page also accepts a `?theme=` @@ -45,6 +49,9 @@ const DEFAULT_THEME_PACKAGE = '@astryxdesign/theme-neutral'; const THEME_SIDEBAR_BREAKPOINT = '@media (max-width: 900px)'; const styles = stylex.create({ + // These styles mirror ThemePackagePage's established responsive shell. The + // fallback swaps only selected-theme content for Skeletons; it does not move + // the heading or change the mobile composition. loadingLayout: { display: 'flex', alignItems: 'flex-start', @@ -58,27 +65,47 @@ const styles = stylex.create({ loadingSidebar: { flex: '0 0 auto', width: 260, + position: 'sticky', + top: 96, display: {default: 'block', [THEME_SIDEBAR_BREAKPOINT]: 'none'}, }, + loadingSidebarCard: { + padding: 'var(--spacing-4)', + }, + loadingActions: { + width: '100%', + }, + loadingThemeList: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--spacing-2)', + }, loadingRight: { flex: '1 1 0', minWidth: 0, width: '100%', display: 'flex', flexDirection: 'column', - gap: 'var(--spacing-6)', + gap: 'var(--spacing-8)', }, loadingMobileContext: { display: {default: 'none', [THEME_SIDEBAR_BREAKPOINT]: 'flex'}, flexDirection: 'column', + gap: 'var(--spacing-5)', + }, + loadingMobileCarousel: { + display: {default: 'none', [THEME_SIDEBAR_BREAKPOINT]: 'flex'}, gap: 'var(--spacing-3)', + overflow: 'hidden', + width: '100%', }, - loadingMobileActions: { - display: 'flex', - gap: 'var(--spacing-2)', + loadingMobileCard: { + flex: '0 0 140px', }, loadingPreview: { height: {default: 720, [THEME_SIDEBAR_BREAKPOINT]: 520}, + width: '100%', + overflow: 'hidden', }, }); @@ -144,10 +171,20 @@ async function SeededThemeExplorer({ } /** - * The theme explorer depends on the request query, so it remains a PPR hole. - * This fallback mirrors its two-column geometry and keeps the footer below the - * viewport instead of collapsing the section to zero height while it streams. + * The selected theme is request-dependent, but the page heading is not. Render + * the exact same ThemeHeading component at the exact same desktop/mobile + * positions as ThemePackagePage, replacing only theme-dependent controls, + * cards, and preview content with Skeletons. */ +function ThemeActionsFallback({index = 1}: {index?: number}) { + return ( + + + + + ); +} + function ThemeExplorerFallback() { return (
+
- - -
- - -
+ + +
+
+ {Array.from({length: 4}, (_, index) => ( +
+ +
+ ))}
- +
diff --git a/apps/docsite/src/components/ThemeHeading.tsx b/apps/docsite/src/components/ThemeHeading.tsx new file mode 100644 index 00000000000..7953e77f155 --- /dev/null +++ b/apps/docsite/src/components/ThemeHeading.tsx @@ -0,0 +1,93 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ThemeHeading.tsx + * @input Receives responsive alignment plus optional preview-mode state + * @output Renders the shared Themes heading, description, docs link, and mode-control slot + * @position Shared by the resolved theme explorer and its PPR fallback so both keep identical geometry + */ + +import type {ReactNode} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {Moon, Sun} from 'lucide-react'; +import {HStack, VStack} from '@astryxdesign/core/Layout'; +import {Heading, Text} from '@astryxdesign/core/Text'; +import {Link} from '@astryxdesign/core/Link'; +import {Button} from '@astryxdesign/core/Button'; +import {Skeleton} from '@astryxdesign/core/Skeleton'; + +const styles = stylex.create({ + titleRow: { + width: '100%', + }, + titleText: { + flex: 1, + minWidth: 0, + }, +}); + +interface ThemeHeadingProps { + align?: 'start' | 'center'; + isMobile?: boolean; + /** Effective preview color mode. Omit while the selected theme is loading. */ + mode?: 'light' | 'dark'; + /** Toggles preview color mode. Omit in the PPR fallback. */ + onToggleMode?: () => void; +} + +/** + * Shared page heading for both the real theme explorer and its PPR fallback. + * The title, copy, link, spacing, and responsive type stay byte-identical; only + * the theme-dependent mode control becomes a same-size skeleton while loading. + */ +export function ThemeHeading({ + align = 'start', + isMobile = false, + mode, + onToggleMode, +}: ThemeHeadingProps) { + const isCentered = align === 'center'; + const modeToggleLabel = + mode === 'dark' + ? 'Switch preview to light mode' + : 'Switch preview to dark mode'; + const modeToggleIcon = + mode === 'dark' ? : ; + const modeControl: ReactNode = onToggleMode ? ( +