import { resolve } from 'node:path' import type { ReactNode } from 'react' import type { SerializedFrame, Substrate, SubstrateDocumentContext, SubstrateRenderContext, SubstrateVariantAxis, } from '../core/substrate' import type { RenderDriver } from '../index' import { renderWithStyles } from '../render/collect-styles' import { importMap } from '../render/documents' import { effectiveThemeSignals, resolveThemeSignals, themeRootAttrs, themeSignalsSeedScript, } from '../render/theme-signals' import { DEVICES, RESPONSIVE, type Theme } from '../ui/shell-core' /** * The built-in DOM substrate: React rendered to HTML, delivered as a browser * document, hydrated by a browser bundle. * * This is Display Case's default and — until a second substrate exists — its * only implementation. Extracting it behind the {@link Substrate} contract is * deliberately a *no-op*: every document it produces is byte-identical to the * one the hard-wired renderer produced, because the interface was designed * around what this path already does rather than the other way round. * * Two things that used to be separate now come through here: the dev server's * render document and the published build's render document differed only by an * importmap (published only) and the live-reload/error-overlay injects (dev * only). Both are parameters of the same template — `ctx.importmap` and * `ctx.hostScripts` — so the two templates collapse into one without either * output changing. */ /** * What a DOM render produces. Opaque to Display Case's core (see the `Frame` * contract): `headStyles` in particular is a DOM-only concern — style-engine * output for the document `` — and lives here rather than in any shared * result type. */ export interface DomFrame { /** Pre-rendered `#root` inner markup; `''` when the case is client-only. */ html: string /** True when the case could not be rendered outside a browser (it threw under * `renderToString`, or is declared `browserOnly`, or does not exist). */ browserOnly: boolean /** The throw's message, for the server to log once per browser-only case. */ error?: string /** Render-time (CSS-in-JS) styling collected by the configured style engines, * as `` markup placed after the document's static styles. `''` when no * engine is configured or none produced styling. */ headStyles: string } export interface DomSubstrateOptions { /** * Render-driver factory for the visual/a11y capture — a headless browser that * opens a render address, screenshots it, and runs axe. Defaults to the * built-in Playwright + axe driver, loaded lazily so the toolchain stays an * optional dependency. * * This is browser-shaped by nature, which is exactly why it belongs to *this* * substrate rather than to the substrate contract: a substrate that * serializes frames directly has no driver, because `render()` + `serialize()` * already is the capture. */ driver?: () => RenderDriver | Promise } /** The theme axis: light/dark, selecting a different rendering. */ const THEME_AXIS: SubstrateVariantAxis = { id: 'theme', label: 'Theme', kind: 'render', values: [ { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, ], default: 'light', } /** * The viewport axis: how wide the stage is, presented around an unchanged * rendering. It constrains the embedded stage rather than the document inside * it, which is what makes it a `stage` axis — switching it needs no re-render. */ const VIEWPORT_AXIS: SubstrateVariantAxis = { id: 'viewport', label: 'Viewport', kind: 'stage', values: [ ...RESPONSIVE.map((p) => ({ value: p.id, label: p.label })), ...DEVICES.map((d) => ({ value: d.id, label: `${d.label} (${d.w}×${d.h})`, })), ], default: 'full', } /** Read the theme off a render context, defaulting as the axis declares. */ function themeOf(ctx: { variants: Record }): Theme { return ctx.variants.theme === 'dark' ? 'dark' : 'light' } /** * Build the isolated render document. Shared by the dev server, the published * build, and the render subcommand, so all three deliver the same bytes for the * same case — the property that makes a snapshot reproducible. */ function domDocument(frame: DomFrame, ctx: SubstrateDocumentContext): string { const theme = themeOf(ctx) const signals = effectiveThemeSignals(ctx.config) // Decorated exhibits (atoms…templates, marked `data-decorated` by the mount) // center their content in the frame: when the exhibit wraps or is narrower // than the frame, its rows sit centered rather than top-left. Inline styles on // a case still win, so an author can opt back to `flex-start`. Pages/flows are // excluded — they own their full-bleed layout and must not be re-centered. const exhibitCenter = 'body[data-decorated] #root>*{justify-content:center;align-content:center}' const bodyAttrs = ctx.params.transparent === '1' ? ' data-decorated style="background:transparent"' : '' // `data-ssr` tells the client whether to adopt the delivered markup (1) or // mount fresh (0 — a client-only case that produced no server markup). const rootAttrs = `${ctx.params.fit === '1' ? ' style="width:fit-content"' : ''} data-ssr="${ctx.prerendered ? '1' : '0'}"` const htmlAttrs = themeRootAttrs(resolveThemeSignals(theme, signals)) const { globalCss, vitrineCss } = ctx.resources // The Vitrine stylesheet follows globalCss so a dogfooded design-system case // paints before scripts; for a non-dogfooding consumer these are inert chrome // rules in a preview document. // // The style engines' collected styling (if any) follows the static ${frame.headStyles}${importMap(ctx.importmap)}
${frame.html}
${themeSignalsSeedScript(signals)}${ctx.hostScripts}${script}` } /** * The DOM substrate, plus the browser driver it captures through. * * `driver` sits on the substrate rather than in {@link SubstrateChecks} because * it is irreducibly browser-shaped: it opens a URL, paints it, screenshots it, * and runs axe. The substrate contract's `capture` is the medium-neutral shape; * this is how *this* medium satisfies it. */ export interface DomSubstrate extends Substrate { /** Render-driver factory; `undefined` ⇒ the lazily-loaded built-in default. */ driver?: () => RenderDriver | Promise } /** * The DOM substrate. Pass it explicitly to configure it * (`domSubstrate({ driver })`); a showcase that configures no substrate gets * this one with default options. */ export function domSubstrate(opts: DomSubstrateOptions = {}): DomSubstrate { return { id: 'dom', variants: [THEME_AXIS, VIEWPORT_AXIS], render(tree: ReactNode, ctx: SubstrateRenderContext): DomFrame { // A component declared `browserOnly` opts out of server rendering: skip // the attempt (no throw, no log) and let the client mount it. if (ctx.clientOnly) return { html: '', browserOnly: true, headStyles: '' } try { // Apply any configured style engines around the case tree so render-time // CSS-in-JS styling (emotion/MUI, styled-components…) is collected and // delivered before scripting. const { html, headStyles } = renderWithStyles( tree, ctx.config.styleEngines, ) return { html, browserOnly: false, headStyles } } catch (err) { // The case — or a component it renders — needs a browser: it touched a // browser-only API (window, layout measurement, canvas…) under // `renderToString`. Don't fail the document; emit no server markup and // let the client mount it. The caller records it so later requests skip // the server attempt and the author gets one log line. return { html: '', browserOnly: true, headStyles: '', error: err instanceof Error ? err.message : String(err), } } }, serialize(frame: DomFrame): SerializedFrame { return { bytes: new TextEncoder().encode(frame.html), ext: 'html' } }, document: domDocument, stage: { // The DOM mount: adopts the delivered markup (or mounts fresh for a // client-only case) and then drives in-place swaps, tweaks, and flow // steps. Resolved to an absolute path so the codegen'd per-component // entry can import it from the build cache, wherever that sits. entry: resolve(import.meta.dir, '..', 'ui', 'render-mount.tsx'), }, // React is the DOM substrate's rendering runtime, so it is always delivered // once across a published build. Declaring it here rather than hard-coding // it in the bundler means a substrate whose stage is not React-based is not // forced to carry it. alwaysShare: ['react', 'react-dom', 'react-dom/client'], checks: { /** * One painted browser page per variant, shared by the visual and * accessibility phases. * * Both must see the same paint — a screenshot and an axe run that * disagreed about which render they described would make a finding * unattributable — and opening a page each would double the browser work * for a run that asks for both, which is the usual CI shape. * * The driver is resolved by the caller and handed in, so the lazily * loaded Playwright/axe toolchain stays out of this module's graph. */ async openVariant(ctx) { const driver = ctx.driver if (!driver) { throw new Error( 'The DOM substrate needs a render driver to capture and audit. ' + 'Install the default toolchain, or supply one with ' + 'domSubstrate({ driver }).', ) } if (!ctx.renderUrl) { throw new Error( 'The DOM substrate captures by painting a served document, so it ' + 'needs the case‘s render address.', ) } const page = await driver.open(ctx.renderUrl, ctx.case) return { // A painted screenshot, not this substrate's serialized document: // a visual regression is about what the case *looks* like. ext: 'png', capture: () => page.screenshot(), audit: (opts) => page.audit(opts), dispose: () => page.dispose(), } }, /** * Design-token conformance for this medium: CSS custom properties. * * Imported lazily so the token checker — and the file-walking it does — * is only loaded when the phase actually runs, matching how the rest of * the optional check toolchain is loaded. A substrate for another medium * has an entirely different style vocabulary (or none), which is why this * belongs to the substrate rather than to the check runner. */ async tokens(ctx) { // The specifier is assembled at runtime so the bundler cannot follow it. // This module is reachable from a *consumer's config* (a showcase that // writes `substrate: domSubstrate(...)`), and that config is bundled for // the **browser** to build each case. A statically-visible import would // drag the token checker — and its `import { Glob } from 'bun'` — into // that browser graph and fail the build outright. The check phases only // ever run under Bun, so deferring resolution to call time is correct, // not a trick. const specifier = ['..', 'checks', 'tokens-check'].join('/') const { checkTokens } = (await import( specifier )) as typeof import('../checks/tokens-check') // `checkTokens` reads the showcase's `tokens.allow` from its own config // resolution, so the allow-list on the context is already honored. const { violations } = await checkTokens(ctx.pkgDir) return violations.map((v) => ({ componentId: '', sourcePath: v.file, severity: 'error' as const, message: `${relativeTo(ctx.pkgDir, v.file)}:${v.line}:${v.column} unknown token ${v.token}${ v.hadFallback ? ' (fallback does not excuse it)' : '' }`, })) }, }, // Capture needs a real browser paint, so this substrate captures through the // driver rather than the contract's headless `serialize(render(...))` // default: a screenshot of an unpainted document is not the case's // appearance. The check phases are wired to it in a later step; the driver // it uses is carried here. driver: opts.driver, } } /** Package-relative path for a finding, so output stays navigable. */ function relativeTo(pkgDir: string, file: string): string { return file.startsWith(`${pkgDir}/`) ? file.slice(pkgDir.length + 1) : file }