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..0db7ab25480 --- /dev/null +++ b/apps/docsite/src/__tests__/static-shell.test.ts @@ -0,0 +1,73 @@ +// 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'); + 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(/ { + // 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..701ae9091cf 100644 --- a/apps/docsite/src/app/(site)/themes/page.tsx +++ b/apps/docsite/src/app/(site)/themes/page.tsx @@ -16,12 +16,18 @@ */ 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 {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=` @@ -40,6 +46,69 @@ export const metadata: Metadata = pageMetadata({ // 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({ + // 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', + 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, + 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-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%', + }, + loadingMobileCard: { + flex: '0 0 140px', + }, + loadingPreview: { + height: {default: 720, [THEME_SIDEBAR_BREAKPOINT]: 520}, + width: '100%', + overflow: 'hidden', + }, +}); + function slugToPackageName(slug: string): string { return `@astryxdesign/theme-${slug}`; } @@ -51,7 +120,7 @@ export default function ThemesPage({ }) { return (
- + }>
@@ -100,3 +169,66 @@ async function SeededThemeExplorer({ return ; } + +/** + * 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/SiteFooter.tsx b/apps/docsite/src/components/SiteFooter.tsx index 0948205c1a4..40aa6cd9fdf 100644 --- a/apps/docsite/src/components/SiteFooter.tsx +++ b/apps/docsite/src/components/SiteFooter.tsx @@ -10,7 +10,6 @@ import {HStack, VStack} from '@astryxdesign/core/Layout'; import {Grid, GridSpan} from '@astryxdesign/core/Grid'; import {Divider} from '@astryxdesign/core/Divider'; import {Section} from '@astryxdesign/core/Section'; -import {useAppShellMobile} from '@astryxdesign/core/AppShell'; import {DocsVersionFooterLink} from './DocsVersionFooterLink'; import { GITHUB_REPO, @@ -31,6 +30,8 @@ import { DiscordLogo, } from './logos'; +const MOBILE = '@media (max-width: 768px)'; + const styles = stylex.create({ siteFooter: { // Match the section rhythm above (responsive); fall back off the home page. @@ -54,8 +55,56 @@ const styles = stylex.create({ display: 'block', color: 'var(--color-icon-secondary)', }, + // Keeps the wrapped link list to a readable measure once it stacks; on + // desktop the links sit in their own grid column and must not be clamped. mobileFooterLinks: { - maxWidth: 320, + maxWidth: {default: 'none', [MOBILE]: 320}, + }, + // The footer is one markup at every width — the layout swaps in CSS, not in + // JS. It used to branch on `useAppShellMobile().isMobile`, which is a + // `useMediaQuery` whose server snapshot is always `false`: the prerendered + // HTML therefore carried the DESKTOP grid at every width, so on a phone the + // wordmark, the link list and the social buttons all painted on top of each + // other in ~80px columns until hydration replaced them. A media query has + // the right answer on the very first paint. + // + // Every override below RESTATES its desktop value in `default` rather than + // leaving it `null`. `xstyle` merges after the component's own styles and a + // `null` there *unsets* the property, so `{default: null, …}` would strip + // VStack's gap and Grid's `display: grid` at desktop width. + stack: { + // VStack gap={4} + gap: {default: 'var(--spacing-4)', [MOBILE]: 'var(--spacing-6)'}, + }, + // `grid-template-columns` (from Grid) and `grid-column` (from GridSpan) are + // inert under `display: flex`, and `flex-direction` is inert under + // `display: grid`, so switching `display` alone turns the row into a + // centered column. + row: { + display: {default: 'grid', [MOBILE]: 'flex'}, + flexDirection: 'column', + alignItems: 'center', + }, + navRow: { + gap: {default: 'normal', [MOBILE]: 'var(--spacing-6)'}, + }, + legalRow: { + gap: {default: 'normal', [MOBILE]: 'var(--spacing-2)'}, + }, + navLinks: { + // HStack gap={4} + gap: {default: 'var(--spacing-4)', [MOBILE]: 'var(--spacing-3)'}, + }, + copyright: { + // Text justify="end" + textAlign: {default: 'end', [MOBILE]: 'center'}, + }, + social: { + // Must stay `nowrap` on desktop: the social buttons sit in a `1fr` grid + // track, and a track only grows past its share to fit its MIN-CONTENT — a + // wrappable row has a one-icon min-content, so the track would stay at + // 1/5 of the row and the icons would wrap onto a second line. + flexWrap: {default: 'nowrap', [MOBILE]: 'wrap'}, }, }); @@ -151,8 +200,6 @@ function LegalLinks() { } export function SiteFooter({year}: {year: number}) { - const {isMobile} = useAppShellMobile(); - // The regex compliance check requires the year to immediately follow the // copyright mark — `©{year}`, no separating space. See PR description. const copyright = `\u00A9${year} Meta Platforms, Inc.`; @@ -175,61 +222,29 @@ export function SiteFooter({year}: {year: number}) { ); - if (isMobile) { - // On narrow viewports the horizontal rows can't fit side by side, so we - // stack the three regions vertically and let the link lists wrap. - return ( -
- - - {astryxLogo} - - - - - - - - - - - - {metaOpenSourceLink} - - - - - {copyright} - - - -
- ); - } - return (
- - + + {astryxLogo} - + - + - + {metaOpenSourceLink} - + {copyright} 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 ? ( +