Skip to content

Configuration

Jake Uskoski edited this page Jun 26, 2026 · 1 revision

🔄 Auto-generated mirror. Canonical source: docs/configuration.md. Edit there — changes here are overwritten by CI.

Configuration

Nav: Quick start · Writing cases · Hierarchy · Tweaks · Theming · Style engines · Documentation panel · Writing placard docs · Testing · CLI · AI agents · Configuration

Display Case is configured by a single display-case.config.ts (or .tsx) file at the root of the package it showcases. The file must default-export defineConfig(...); if it does not, the CLI errors. defineConfig is an identity helper that exists purely to give the config file full type-checking and inference.

// display-case.config.ts
import { defineConfig } from '@awarebydefault/display-case'
import { ThemeProvider } from './src/components/theme-provider'

export default defineConfig({
  title: 'Display Case',
  roots: ['src/components/**/*.case.tsx'],
  globalStyles: ['./src/tokens.css', './src/components.css'],
  decorator: ThemeProvider,
  baselineDir: 'baselines',
})

The CLI looks for display-case.config.ts then display-case.config.tsx in the package directory.

Reference

Key Type Required Default Description
title string yes Shown in the browsing chrome and the manifest.
roots string[] yes Globs (relative to the package) locating *.case.tsx files.
primer string no none Path (relative to the package) to an .mdx document rendered as the Primer reading page. When set, a Primer tab joins the mode switch. See primer.
landing 'primer' | 'components' | 'exhibits' no first present mode Which browse mode the chrome lands on at /. Honored only when that mode is present; otherwise the first present mode (primer → components → exhibits). See landing.
nav NavConfig no none Information-architecture configuration for the Exhibits mode: folder-derivation toggle, surface→group mapping, and group order/labels/default-collapsed. See nav.
globalStyles string[] no none CSS entrypoints (relative to the package) injected into previews.
decorator ComponentType<{ children, level?, sourcePath?, area? }> no none Wrapper rendered around every case; also receives the active case's level, sourcePath, and area so it can wrap page/flow cases in app chrome.
styleEngines StyleEngine[] no none Engines that collect render-time (CSS-in-JS) styling — emotion/MUI, styled-components — during the server render and deliver it before scripting. Pair with decorator. See styleEngines and Style engines.
share string[] no none Runtime libraries to deliver once across a published showcase, beyond React (always shared). A library shared by several components is built into one cacheable vendor bundle every surface resolves to, instead of inlined into each per-component bundle. Affects only display-case publish. See share.
baselineDir string no .display-case/baselines Where visual-regression baselines are stored.
tokens { allow?: string[] } no none Design-token conformance options for --tokens. allow lists custom-property names the package may reference but does not itself define (e.g. host-app-provided tokens). See Testing.
providers { driver?, diff? } no built-in Override the visual-regression backend. When unset, the built-in Playwright/axe driver and pixelmatch/pngjs diff are loaded lazily. See providers.
check { defaultPhases?, concurrency?, structure?, graphBudget? } no none Tune the check command: which phases run by default, how many variants the a11y/visual phases scan concurrently, the structure phase's rules and severities, and the bundle-graph budgets. See check.
a11y { enabled?, themes?, exclude?, startup? } no off Live accessibility surfacing in the running browse chrome. See a11y.

title

The library name. Appears in the browse shell header and in /manifest.json as the top-level title.

roots

One or more glob patterns, resolved relative to the package directory, that select your case files. Matches under any node_modules/ are ignored, and results are de-duplicated. Most libraries need just one:

roots: ['src/components/**/*.case.tsx']

globalStyles

CSS files concatenated and injected into both the browse shell and the isolated render document, so components render with their real tokens and styles. Paths are resolved relative to the package; a listed file that does not exist is skipped silently. See Theming.

globalStyles: ['./src/tokens.css', './src/components.css']

primer

Path (relative to the package) to an .mdx document rendered as the Primer — a long-form reading page with embedded live specimens, shown via a Primer / Cases mode switch the chrome adds to the sidebar. The MDX is bundled into its own isolated frame (like /render), so it can import any component — case files and arbitrary .tsx — without risking the browse chrome.

primer: './src/design-system/primer.mdx'

Wrap each live specimen in the <Display> contract (provided to the MDX automatically — no import needed):

import { Button } from './components'

# Our design system

The wall text that orients you before browsing the cases.

<Display title="Button" subtitle="The one true action" theme="dark">
  <Button variant="accent">Snapshot</Button>
</Display>

<Display> props:

Prop Type Description
title string Specimen title; also the sidebar table-of-contents entry and scroll anchor.
subtitle string Optional one-line description under the title.
theme 'light' | 'dark' Optional — forces a theme scope inside the card only, so a dark-mode component reads correctly on a light page (and vice versa). Omit to inherit the page theme.
flush boolean Optional — drop the card's own border and padding so a single self-bordered child fills the box edge-to-edge (avoids a box-within-a-box).
appSurface boolean Optional — paint the card with the consumer design system's own canvas (--color-bg/--color-fg, the same tokens the /render frame paints) instead of the Vitrine's --dc-bg, so the specimen sits on the exact background the real app gives it. Combine with theme for the app's themed surface; degrades to --dc-bg when no --color-bg is defined.

The sidebar reflects each <Display> title as a table-of-contents entry, grouped under the #/## heading that precedes it — each heading is itself a navigable, collapsible group header (the # page title doubles as the "top of page" entry). Any <Display>s before the first heading fall into a leading "Contents" group. Long entries truncate with an ellipsis. Scrollspy highlights the heading or section in view. Toggling the chrome's light/dark theme drives the Primer too.

Supported Primer syntax

The Primer is compiled by Display Case's own small MDX compiler (mdx-lite), not the full MDX toolchain. It supports a deliberately constrained dialect — exactly enough for prose interleaved with live specimens — and nothing more. Within these rules a Primer behaves like ordinary TypeScript + Markdown:

  • ES import / export statements at the start of a line (column 0), single- or multi-line. They are passed through verbatim and resolved by the bundler, so a Primer imports components exactly like any .tsx module.
  • Prose in the same CommonMark + GFM flavour as placard docs — headings, bold/italic, inline code, lists, links, GFM tables, strikethrough. Raw HTML is not rendered, and fenced code is not syntax-highlighted (the same two limits).
  • Block-level specimens — a JSX element that begins a line at column 0 (a capitalized tag like <Display …> or a <> fragment), consumed through its matching close. Standard JSX expression props work, e.g. style={{ fontSize: '1rem' }}. Author-imported components resolve through their imports; <Display> is provided automatically.
  • A fenced code block stays prose even when its contents look like a specimen — a ```mdx sample containing <Display> renders as code, never as a live element.

Not supported (by construction — keep specimens as their own blocks):

  • Inline JSX inside a prose paragraph. Put a specimen on its own line at column 0, set off by blank lines.
  • Markdown syntax inside JSX children. Text inside a specimen is literal JSX, not Markdown.
  • {expression} interpolation in prose. Expressions belong inside a specimen's JSX, not in the surrounding text.

These constraints are checked: a Primer that can't be parsed, or that has no <Display> specimen or no prose, fails the primer-present-and-used structure check rather than rendering wrong.

landing

Which browse mode the chrome shows first when you open the root path (/): the 'primer' reading page, the 'components' kit, or the 'exhibits' surfaces. Use 'components' when the kit is the main event and the Primer is supplementary:

primer: './src/design-system/primer.mdx',
landing: 'components',

The configured mode is honored only when it is present (has content); otherwise the chrome falls back to the first present mode in the order primer → components → exhibits (so the default, with a Primer configured, is the Primer). This setting governs only the bare / landing — a case deep link (/c/... or /e/...) always opens that case, and /primer always opens the Primer.

nav

Information architecture for the Exhibits mode — how pages and flows are grouped. All fields are optional:

nav: {
  // Derive a surface's group from its case-file folder. Default: true.
  deriveFromFolder: true,
  // Map surfaces to groups when their folders don't mirror the IA. First match
  // wins; consulted after an explicit `meta.group` and folder derivation.
  surface: [
    { id: 'pricing', group: 'Marketing/Plans' },   // by component id, or a glob
    { area: 'admin', group: 'Admin' },              // or by the case's `area`
  ],
  groups: {
    order: ['Onboarding', 'Marketing', 'App'],      // unlisted groups follow
    labels: { app: 'Signed-in app' },               // rename a derived segment
    collapsed: ['Admin'],                           // collapsed by default
  },
  // How a flow is distinguished from a page in the Exhibits sidebar:
  // 'tag' (default) appends a high-vis `flow` pill after the name;
  // 'glyph' prefixes the flow row with a leading glyph. Either way a flow's
  // step rows are numbered and pages render plain.
  flowMarker: 'tag',
}

A surface's group resolves first-match-wins: explicit meta.group → folder (relative to the matched roots glob; route-group parens like (marketing) and leading underscores are stripped, segments title-cased) → a surface rule → a default group. order/labels/collapsed match a group by its segment or its /-joined path, case-insensitively; naming a group no surface resolves to is reported by the nav-groups-resolve structure check (a warning). See Hierarchy → Components and Exhibits.

decorator

A single React component that wraps every rendered case (for a theme provider, context, or a fixed frame). It is applied inside React StrictMode. See Theming.

import { ThemeProvider } from './src/components/theme-provider'
// …
decorator: ThemeProvider,

Besides children, the decorator receives the active case's identity so it can wrap page and flow cases in app chrome (a nav header, sidebar, footer) while leaving smaller components bare:

decorator?: ComponentType<{
  children: ReactNode
  level?: HierarchyLevel   // 'atom' | … | 'page' | 'flow' (from defineCases/defineFlow)
  sourcePath?: string      // case file path, package-relative (folder convention)
  area?: string            // free-form tag from the case's meta (overrides sourcePath)
}>

Per-area app chrome. A consuming app's decorator can render a page the way it looks in the real app — inside its actual navigation/layout — by branching on these:

Say a consuming app has two areas — a marketing site and the signed-in app — each with its own header. A decorator can wrap page/flow cases in the matching chrome and leave smaller components bare:

function Decorator({ children, level, sourcePath, area }) {
  const inApp = level === 'page' || level === 'flow'
  // Resolve which chrome to use: an explicit `area` tag wins; otherwise infer
  // from the `page-cases/<area>/…` folder; otherwise render bare.
  const which =
    area ?? sourcePath?.match(/(?:^|\/)page-cases\/([^/]+)\//)?.[1]
  let content = children
  if (inApp && which === 'marketing') content = <SiteHeader variant="marketing">{children}</SiteHeader>
  else if (inApp && which === 'app') content = <SiteHeader variant="app">{children}</SiteHeader>
  return <Providers>{content}</Providers>
}

Cases tagged marketing (or under page-cases/marketing/…) render inside the marketing header; app cases render inside the signed-in header.

Display Case is unopinionated here: it supplies the signals and mandates no vocabulary or folder layout. Tag a case via meta.area (see Writing cases), or organize cases into area folders and read sourcePath — whichever suits the package. Chrome that itself renders router <Link>s (e.g. a nav bar) needs a router in context; if your nav uses a router, provide a tiny in-memory router inside the chrome so the links resolve.

styleEngines

For components styled by a runtime CSS-in-JS library — emotion (and therefore Material UI), styled-components, and peers — that emit their CSS as a side effect of rendering. A style engine collects that styling during the server render and delivers it in the isolated /render and Primer documents before scripting, so those surfaces are styled without executing scripts (no flash, and chrome-free snapshots come back styled).

styleEngines: [emotionEngine]

Each engine is a factory invoked once per render for an isolated style store:

type StyleEngine = () => StyleCollector
interface StyleCollector {
  wrap(node: ReactNode): ReactNode      // provide the library's store (e.g. emotion CacheProvider)
  collect(renderedHtml: string): string // return the <head> style markup that render used
}

styleEngines (the server-side extractor) pairs with decorator (the client/SSR provider, e.g. a MUI ThemeProvider). Omit styleEngines entirely and the documents are byte-identical to their engine-free form. The full recipe — emotion/MUI flagship, styled-components, and when to use globalStyles instead — is in Style engines.

share

A published showcase builds each component into its own isolated browser bundle (so no single bundler pass ever has to hold the whole catalog — the design that keeps a large showcase buildable). React is always delivered once as a shared, content-hashed vendor bundle that every surface references via an importmap, not copied into each bundle. share extends that same treatment to any other runtime library:

share: ['markdown-to-jsx', '@emotion/react', '@emotion/styled', '@acme/design-tokens']

Reach for it when several components import the same library and you don't want each per-component bundle to carry its own copy. Two reasons:

  • Size — a library shared by N components is downloaded once site-wide instead of N times (each copy otherwise cached separately, never reused).
  • Correctness — a library that keeps internal state and must exist as a single instance (a CSS-in-JS engine's style cache/context) behaves correctly only when every surface resolves to one copy. Declaring it share guarantees that, the same way React is always collapsed to one copy.

Notes:

  • List the package ('markdown-to-jsx'), an explicit subpath ('@acme/icons/solid'), or a monorepo workspace package ('@acme/design-tokens') — a package defined in your repo is shared on the client just like a published one. A deep import you don't list stays inlined (still correct, just not shared).
  • Assumes one installed version per shared package across the showcase (true for peer dependencies by definition). Don't share a library you intentionally run at two major versions.
  • Affects only display-case publish, never the dev server. With nothing declared, React is still shared and the output is what you'd get without share at all.
  • Not sure what to list? display-case publish prints any library it inlined into more than one component as a candidate — add the ones worth sharing.

baselineDir

Where the visual-regression runner reads and writes baseline PNGs. Defaults to the gitignored cache at .display-case/baselines (local-only). Provide a path — relative to the package or absolute — to point at a committed directory and gate CI on shared baselines. See Testing.

baselineDir: 'baselines'          // committed, relative to the package
// or
baselineDir: '/abs/path/to/baselines'

providers

The visual-regression backend is pluggable. providers lets you replace either half of it — the driver that opens a case render URL and captures it, the diff that compares a capture against its baseline, or both.

export default defineConfig({
  title: 'Display Case',
  roots: ['src/components/**/*.case.tsx'],
  providers: {
    driver: () => myDriver(),   // optional; default = built-in Playwright + axe
    diff: myDiff,               // optional; default = built-in pixelmatch/pngjs
  },
})

When providers is omitted (or a half is left unset), Display Case falls back to its built-in default for that half. The default is lazy and optional: the Playwright/axe driver and pixelmatch/pngjs diff are imported only when a default-backed check --a11y/--visual actually runs, so browsing, snapshotting, and init never need them. See Testing. Setting a custom provider replaces the default for that half and removes the need for those packages entirely.

The reference implementations are the built-ins themselves — src/checks/providers/playwright-driver.ts and src/checks/providers/pixelmatch-diff.ts. A custom provider need only satisfy the interface.

The CaseContext argument

Both providers receive the identity of the case being rendered, so an identity-aware provider can vary its behavior per case (a per-case tolerance, a name-keyed hosted service such as Percy or Chromatic, richer reporting). Pure providers simply ignore it.

interface CaseContext {
  componentId: string
  caseId: string
  theme: 'light' | 'dark'
  width: number
}

diff

type DiffFn = (
  input: { baseline: Uint8Array; actual: Uint8Array },
  ctx: CaseContext & { baselinePath: string },
) => DiffResult | Promise<DiffResult>

interface DiffResult {
  changed: boolean
  mismatch?: number       // e.g. count of differing pixels, for reporting
  diffImage?: Uint8Array  // written next to the baseline on a change
}

A custom diff that loosens tolerance for one noisy case and leaves every other case strict:

import pixelmatch from 'pixelmatch'
import { PNG } from 'pngjs'
import { defineConfig, type DiffFn } from '@awarebydefault/display-case'

const tolerantDiff: DiffFn = ({ baseline, actual }, ctx) => {
  const a = PNG.sync.read(Buffer.from(baseline))
  const b = PNG.sync.read(Buffer.from(actual))
  if (a.width !== b.width || a.height !== b.height) return { changed: true }
  // Allow a few stray pixels on the gradient case; everything else stays exact.
  const allowed = ctx.caseId === 'gradient' ? 50 : 0
  const diff = new PNG({ width: a.width, height: a.height })
  const mismatch = pixelmatch(a.data, b.data, diff.data, a.width, a.height, {
    threshold: 0.1,
  })
  return mismatch > allowed
    ? { changed: true, mismatch, diffImage: PNG.sync.write(diff) }
    : { changed: false, mismatch }
}

export default defineConfig({
  title: 'Display Case',
  roots: ['src/components/**/*.case.tsx'],
  providers: { diff: tolerantDiff },
})

driver

interface RenderDriver {
  open(url: string, ctx: CaseContext): Promise<RenderedPage>
  close(): Promise<void>
}

interface RenderedPage {
  screenshot(): Promise<Uint8Array>
  audit(): Promise<A11yViolation[]>  // [] if the driver skips auditing
  dispose(): Promise<void>
}

interface A11yViolation {
  id: string
  help: string
  nodes: number
}

driver is a factory: it is called once, returns a RenderDriver reused across every case, and close() runs when the check finishes. Each open() yields a RenderedPage you can screenshot() and audit(), then dispose(). A sketch:

import { defineConfig, type RenderDriver } from '@awarebydefault/display-case'

function myDriver(): RenderDriver {
  const browser = /* launch your headless browser once */
  return {
    async open(url, ctx) {
      const page = /* open `url`; ctx.theme / ctx.width are already in the URL */
      return {
        screenshot: () => page.capture(),
        audit: async () => [],        // return [] to skip a11y auditing
        dispose: () => page.close(),
      }
    },
    close: () => browser.close(),
  }
}

export default defineConfig({
  title: 'Display Case',
  roots: ['src/components/**/*.case.tsx'],
  providers: { driver: myDriver },
})

Compare against the built-in createPlaywrightDriver, which launches Chromium at a fixed 1024×768 viewport with reduced motion and runs a WCAG 2 A/AA axe audit.

check

Tunes the check command. Two independent parts:

check: {
  // Which phases run in the default (no-flag) run. Unset ⇒ included; set false to
  // opt out. An opted-out phase still runs when named explicitly (e.g. --visual).
  defaultPhases: { visual: false },

  // How many variants the a11y/visual phases scan concurrently. Default 4.
  concurrency: 4,

  structure: {
    // Treat every structure warning as an error for the run (same as --strict).
    strict: false,
    // Per-rule overrides. Each rule is on at its default severity unless set here.
    rules: {
      'primer-present-and-used': false,        // disable a rule
      'composes-lower-level': 'error',          // enable/retune severity
      'case-placard-coverage': { ignore: ['**/internal/**'] }, // skip paths
      'atom-purity': {},                        // enable an opt-in rule at default severity
      'level-fit': { thresholds: { molecule: 8 } }, // rule-specific options
    },
  },

  // Budgets for the bundle-graph (`--graph`) phase. Unset fields use the defaults.
  graphBudget: { modules: 1500, perPackage: 400 },
}
  • defaultPhases — a Partial<Record<'tokens' | 'a11y' | 'visual' | 'structure' | 'ssr' | 'graph', boolean>>. Drop a phase from the bare check run (e.g. visual when no baselines are committed) while keeping it available via its flag.
  • concurrency — how many variants the render phases (a11y/visual) scan at once, each on its own page from a shared browser. Default 4; override per run with --concurrency=N. A custom providers.driver must tolerate concurrent open() calls (set 1 if it can't). See Testing → Reporting and concurrency.
  • structure.rules[id]false disables the rule; 'warn'/'error' enables it at that severity; an options object ({ severity?, ignore?, thresholds? }) enables it with overrides. Unset ⇒ the rule's default. The rule ids, defaults, and escape-hatch markers are listed in Testing → Structure checks.
  • structure.strict — escalate all structure warnings to errors (the config equivalent of check --strict).
  • graphBudget — budgets for the --graph phase, which measures each component's real bundled module graph (built in isolation, so measuring can never crash the tool). modules (default 1500) warns when a component's total module count exceeds it; perPackage (default 400) warns when a single dependency contributes more modules than it — the barrel-import signal (e.g. importing a whole icon set). Warnings are advisory; --strict makes them errors. The budgets are deliberately loose early-warnings — the true crash threshold is machine-dependent — so tighten them for your codebase as needed.

a11y

Surface accessibility results in the running browse chrome — a per-variant marker in the nav rail and an Accessibility panel beside the rendered case.

a11y: {
  enabled: true,                 // default false — opt-in
  themes: ['light', 'dark'],     // default both; scanned + reflected per theme
  exclude: ['color-contrast'],   // axe rule ids to skip
  startup: 'cached',             // default 'off' — how the nav is populated at boot
}
  • Off by default. It needs the same optional Playwright + axe toolchain as a default-backed check (see providers); when that toolchain can't launch, the panel shows an unavailable state and the server still browses normally — it never fails to start.
  • On demand + cached. Only the variant you're viewing is scanned, lazily; results are cached under .display-case/a11y/ and reused until that variant's rendered output changes (judged by a transitive-import content hash). Scans run on a background queue and never block browsing.
  • startup populates the nav at boot (only meaningful with enabled; default 'off'). It does not change the on-demand-per-viewed-variant behavior — it only decides what the nav shows before you start clicking:
    • 'off' — no boot-time population; a variant's marker appears only once viewed.
    • 'cached' — fill markers from reusable cached results, running no scans; uncached or stale variants stay unmarked until viewed.
    • 'refresh' — additionally scan every uncached or stale variant at boot, surfacing each verdict as it lands (reusing fresh cache without re-scanning). Work rides the same background queue, so browsing stays responsive. Nothing is scanned if the toolchain can't launch.
  • enabled gates only the live surface. The display-case check --a11y CI gate runs whenever invoked regardless of enabled, but honors the shared themes / exclude here so the panel and the gate agree on what counts as a violation.

The cache directory

Display Case writes generated artifacts (the bundled output and the auto-generated render entry) to .display-case/ inside the package, and — unless overridden — keeps baselines under .display-case/baselines/. Add .display-case/ to .gitignore; it is a derived cache.

Clone this wiki locally