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 ( +
+ +
+
+ + +
+ + +
+
+
+ +
+
+
+ ); +} 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 @@ -1,251 +1,270 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; import * as stylex from '@stylexjs/stylex'; import {Text} from '@astryxdesign/core/Text'; import {Link} from '@astryxdesign/core/Link'; import {Button} from '@astryxdesign/core/Button'; 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, DISCORD_URL, FACEBOOK_URL, INSTAGRAM_URL, THREADS_URL, X_URL, } from '../constants'; import { AstryxLogo, GitHubLogo, ThreadsLogo, XLogo, InstagramLogo, FacebookLogo, MetaOpenSourceLogo, 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. paddingTop: 'var(--astryx-marketing-section-gap, calc(var(--spacing-12) * 2))', }, astryxLogo: { height: 18, width: 'auto', display: 'block', color: 'var(--color-icon-secondary)', }, socialIcon: { width: 16, height: 16, display: 'block', }, metaOpenSourceLogo: { height: 14, width: 'auto', 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'}, }, }); const FOOTER_LINKS: ReadonlyArray<{ label: string; href: string; }> = [ {label: 'Docs', href: '/docs/getting-started'}, {label: 'Components', href: '/components'}, {label: 'Templates', href: '/templates'}, {label: 'Themes', href: '/themes'}, {label: 'Playground', href: '/playground'}, {label: 'Blog', href: '/blog'}, {label: 'Community', href: '/community'}, {label: 'Changelog', href: '/changelog'}, ]; const SOCIAL_LINKS: ReadonlyArray<{ label: string; href: string; Icon: (props: React.SVGProps) => React.ReactElement; }> = [ {label: 'GitHub', href: GITHUB_REPO, Icon: GitHubLogo}, {label: 'Discord', href: DISCORD_URL, Icon: DiscordLogo}, {label: 'Facebook', href: FACEBOOK_URL, Icon: FacebookLogo}, {label: 'Instagram', href: INSTAGRAM_URL, Icon: InstagramLogo}, {label: 'Threads', href: THREADS_URL, Icon: ThreadsLogo}, {label: 'X', href: X_URL, Icon: XLogo}, ]; const LEGAL_LINKS: ReadonlyArray<{label: string; href: string}> = [ {label: 'Terms of use', href: 'https://opensource.fb.com/legal/terms'}, {label: 'Privacy policy', href: 'https://opensource.fb.com/legal/privacy'}, ]; function NavLinks() { return ( <> {FOOTER_LINKS.map(item => ( {item.label} ))} ); } function SocialButtons() { return ( <> {SOCIAL_LINKS.map(social => (