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 ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+ Themes
+
+ {modeControl}
+
+
+
+ Astryx comes with a default theme built in. To make it your own, copy
+ any theme you see here into a theme file you own.
+
+
+ Learn how theming works
+
+
+
+ );
+}
diff --git a/apps/docsite/src/components/ThemePackagePage.tsx b/apps/docsite/src/components/ThemePackagePage.tsx
index 2a2e910e94c..1b78e7c0dfc 100644
--- a/apps/docsite/src/components/ThemePackagePage.tsx
+++ b/apps/docsite/src/components/ThemePackagePage.tsx
@@ -8,8 +8,8 @@ import * as stylex from '@stylexjs/stylex';
import type {StyleXStyles} from '@stylexjs/stylex';
import {usePathname, useRouter} from 'next/navigation';
import {Sun, Moon} from 'lucide-react';
-import {HStack, VStack} from '@astryxdesign/core/Layout';
-import {Heading, Text} from '@astryxdesign/core/Text';
+import {VStack} from '@astryxdesign/core/Layout';
+import {Text} from '@astryxdesign/core/Text';
import {Card} from '@astryxdesign/core/Card';
import {Carousel} from '@astryxdesign/core/Carousel';
import {Theme} from '@astryxdesign/core/theme';
@@ -17,7 +17,6 @@ import type {DefinedTheme} from '@astryxdesign/core/theme';
import {Button} from '@astryxdesign/core/Button';
import {CodeBlock} from '@astryxdesign/core/CodeBlock';
import {Popover} from '@astryxdesign/core/Popover';
-import {Link} from '@astryxdesign/core/Link';
import {LinkProvider} from '@astryxdesign/core/Link';
import {SelectableCard} from '@astryxdesign/core/SelectableCard';
import {Selector} from '@astryxdesign/core/Selector';
@@ -33,6 +32,7 @@ import {templates} from '../generated/templateRegistry';
import {trackCopy, trackOpenPlayground, trackToggle} from '../lib/analytics';
import {useThemeMode} from '../app/providers';
import commandBlockStyles from './ThemeCommandBlock.module.css';
+import {ThemeHeading} from './ThemeHeading';
// Raw source of the theme-showcase page template (embedded as a string in
// the generated template registry). Used to prepopulate the playground's
@@ -212,15 +212,6 @@ const styles = stylex.create({
actionButton: {
width: '100%',
},
- // Title row — heading takes the leading flex space (minWidth:0 lets it
- // shrink) so the icon-only mode toggle stays pinned to the trailing edge.
- titleRow: {
- width: '100%',
- },
- titleText: {
- flex: 1,
- minWidth: 0,
- },
// The command snippet in the popover — CodeBlock with container="section"
// (no border/radius of its own), so paint the muted inset here. The extra
// inline-end padding keeps wrapping text clear of the absolutely-positioned
@@ -532,64 +523,6 @@ const PICKER_OVERRIDES: Record<
},
};
-function ThemeHeading({
- align = 'start',
- isMobile = false,
- mode,
- onToggleMode,
-}: {
- align?: 'start' | 'center';
- isMobile?: boolean;
- /** Effective preview color mode — drives the toggle beside the title. */
- mode: 'light' | 'dark';
- /** Toggle the preview color mode. */
- onToggleMode: () => void;
-}) {
- const isCentered = align === 'center';
- const modeToggleLabel =
- mode === 'light'
- ? 'Switch preview to dark mode'
- : 'Switch preview to light mode';
- const modeToggleIcon =
- mode === 'light' ? : ;
-
- return (
-
- {/* Title row — heading takes the leading flex space so the icon-only
- mode toggle stays pinned to the trailing edge. display-3 in the
- 260px sidebar; display-2 in the narrow layout. */}
-
-
- Themes
-
-
-
- {/* Body + docs link on its own line below it. */}
-
-
- Astryx comes with a default theme built in. To make it your own, copy
- any theme you see here into a theme file you own.
-
-
- Learn how theming works
-
-
-
- );
-}
-
interface ThemeActionsProps {
selectedPkgName: string;
/** Playground deep link for the selected theme + showcase source. */