diff --git a/apps/docsite/src/__tests__/static-shell.test.ts b/apps/docsite/src/__tests__/static-shell.test.ts
new file mode 100644
index 00000000000..d6ae12c7fbd
--- /dev/null
+++ b/apps/docsite/src/__tests__/static-shell.test.ts
@@ -0,0 +1,63 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file Guards the docsite's query-driven PPR boundaries and global footer.
+ * @input Reads the relevant docsite source files
+ * @output Invariants for narrow Suspense boundaries, named fallbacks, and
+ * CSS-driven footer responsiveness
+ * @position Cross-cutting meta-test; no runtime behavior of its own
+ *
+ * The docsite runs with `cacheComponents: true` (Partial Prerendering).
+ * Request state such as `searchParams` is valid, but everything up to its
+ * nearest Suspense boundary becomes a PPR hole. The component detail page and
+ * theme explorer deliberately keep their existing deep-link behavior; these
+ * tests make sure their boundaries stay narrow and never regress to an empty
+ * fallback. The global footer must be CSS-responsive because a JavaScript
+ * media query cannot know the viewport during prerendering.
+ */
+
+import {describe, it, expect} from 'vitest';
+import {readFileSync} from 'node:fs';
+import {join} from 'node:path';
+
+const SRC_DIR = join(__dirname, '..');
+
+function source(path: string): string {
+ return readFileSync(join(SRC_DIR, path), 'utf8');
+}
+
+describe('docsite static shell', () => {
+ it('keeps the component heading outside the query-dependent boundary', () => {
+ const detail = source(
+ 'components/component-detail/ComponentDetailClient.tsx',
+ );
+
+ expect(detail).toContain('const searchParams = useSearchParams()');
+ expect(detail).toContain(
+ 'fallback={}',
+ );
+ expect(detail).not.toMatch(/]*fallback=\{null\}[\s\S]*?>/);
+ expect(detail.indexOf('')).toBeLessThan(
+ detail.indexOf(' {
+ const themes = source('app/(site)/themes/page.tsx');
+
+ expect(themes).toContain('const params = await searchParams');
+ expect(themes).toContain('fallback={}');
+ expect(themes).not.toMatch(/]*fallback=\{null\}[\s\S]*?>/);
+ });
+
+ it('the site footer does not branch on a media query', () => {
+ // SiteFooter renders on every route, docs and marketing alike, so it is
+ // part of the static shell at every width. Its responsive layout has to be
+ // CSS (see the MOBILE media query in SiteFooter.tsx), because a JS branch
+ // can only ever prerender one of the two arms.
+ const footer = source('components/SiteFooter.tsx');
+ expect(footer).not.toMatch(
+ /import[^;]*(?:useAppShellMobile|useMediaQuery)[^;]*from/,
+ );
+ });
+});
diff --git a/apps/docsite/src/app/(site)/themes/page.tsx b/apps/docsite/src/app/(site)/themes/page.tsx
index 7258644a006..5fa707c29a1 100644
--- a/apps/docsite/src/app/(site)/themes/page.tsx
+++ b/apps/docsite/src/app/(site)/themes/page.tsx
@@ -1,102 +1,175 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
/**
* Themes page — /themes
*
* Single canonical surface for browsing every Astryx theme. Renders the
* full live ThemePackagePage (sidebar picker + themed preview
* mockup + card showcase), seeded with the Neutral theme as the
* default selection.
*
* The legacy per-theme route at /themes/ still resolves —
* it now redirects here with ?theme=, which this page reads
* to preselect the right theme in the sidebar so deep links from
* docs, search, and shared URLs land on the requested theme rather
* than the default seed.
*/
import type {Metadata} from 'next';
+import * as stylex from '@stylexjs/stylex';
import {Suspense} from 'react';
import {notFound} from 'next/navigation';
import {Section} from '@astryxdesign/core/Section';
+import {Skeleton} from '@astryxdesign/core/Skeleton';
import {packages} from '../../../generated/packageRegistry';
import {themeObjects} from '../../../generated/themeRegistry';
import {ThemePackagePage} from '../../../components/ThemePackagePage';
import {pageMetadata} from '../../../lib/pageMetadata';
// Static canonical metadata for /themes. The page also accepts a `?theme=`
// param to preselect the picker, but every variant is the same surface, so the
// canonical stays the bare /themes path to avoid duplicate-URL dilution.
export const metadata: Metadata = pageMetadata({
title: 'Themes',
description:
'Browse and preview every Astryx theme and see how design tokens, type, and components restyle across the gallery.',
path: '/themes',
});
// Default seed for the page — the picker opens with this theme
// selected on first visit. Neutral is the most restrained / brand-
// neutral theme in the gallery, so it sets a calm baseline before
// users browse into the more expressive themes (Y2K, Butter, etc.).
const DEFAULT_THEME_PACKAGE = '@astryxdesign/theme-neutral';
+const THEME_SIDEBAR_BREAKPOINT = '@media (max-width: 900px)';
+
+const styles = stylex.create({
+ loadingLayout: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 'var(--spacing-6)',
+ minHeight: {default: 1000, [THEME_SIDEBAR_BREAKPOINT]: 760},
+ flexDirection: {
+ default: 'row',
+ [THEME_SIDEBAR_BREAKPOINT]: 'column',
+ },
+ },
+ loadingSidebar: {
+ flex: '0 0 auto',
+ width: 260,
+ display: {default: 'block', [THEME_SIDEBAR_BREAKPOINT]: 'none'},
+ },
+ loadingRight: {
+ flex: '1 1 0',
+ minWidth: 0,
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 'var(--spacing-6)',
+ },
+ loadingMobileContext: {
+ display: {default: 'none', [THEME_SIDEBAR_BREAKPOINT]: 'flex'},
+ flexDirection: 'column',
+ gap: 'var(--spacing-3)',
+ },
+ loadingMobileActions: {
+ display: 'flex',
+ gap: 'var(--spacing-2)',
+ },
+ loadingPreview: {
+ height: {default: 720, [THEME_SIDEBAR_BREAKPOINT]: 520},
+ },
+});
+
function slugToPackageName(slug: string): string {
return `@astryxdesign/theme-${slug}`;
}
export default function ThemesPage({
searchParams,
}: {
searchParams: Promise<{theme?: string | string[]}>;
}) {
return (
-
+ }>
);
}
async function SeededThemeExplorer({
searchParams,
}: {
searchParams: Promise<{theme?: string | string[]}>;
}) {
// ?theme= preselects the picker. Falls back to the Neutral
// seed if the param is missing, malformed, or names a theme that
// isn't in the registry (so a stale link doesn't 404 on us — the
// user still lands on the explorer with a sensible default).
const params = await searchParams;
const rawSlug = params.theme;
const slug = Array.isArray(rawSlug) ? rawSlug[0] : rawSlug;
const requestedPkgName = slug ? slugToPackageName(slug) : null;
const requestedPkg = requestedPkgName
? packages.find(p => p.name === requestedPkgName)
: undefined;
const requestedTheme = requestedPkgName
? themeObjects[requestedPkgName]
: undefined;
// Use the requested theme if it resolved to a real package + theme
// object; otherwise fall back to the default seed so stale links
// still land on a usable page rather than a 404.
const seedPkg =
requestedPkg && requestedTheme
? requestedPkg
: packages.find(p => p.name === DEFAULT_THEME_PACKAGE);
const seedTheme =
requestedPkg && requestedTheme
? requestedTheme
: themeObjects[DEFAULT_THEME_PACKAGE];
if (!seedPkg || !seedTheme) {
// Defensive: only fires if the @astryxdesign/theme-neutral package is
// ever removed from the workspace, which would break the entire
// themes section anyway.
notFound();
}
return ;
}
+
+/**
+ * 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.
+ */
+function ThemeExplorerFallback() {
+ return (
+