Skip to content

Writing Cases

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

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

Writing cases

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

A case file is the unit of the showcase: a *.case.tsx file colocated with the component it demonstrates, default-exporting one call to defineCases or defineFlow.

// src/components/tweak-control.case.tsx
import { defineCases } from '@awarebydefault/display-case'
import { TweakControl } from './tweak-control'

export default defineCases('TweakControl', {
  Kinds: () => (
    <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
      <TweakControl kind="text" label="Label" value="Save" />
      <TweakControl kind="boolean" label="Disabled" value={false} />
      <TweakControl kind="choice" label="Variant" options={['sm', 'md', 'lg']} value="md" />
    </div>
  ),
  Boolean: () => <TweakControl kind="boolean" label="Disabled" value={true} />,
  Disabled: () => <TweakControl kind="text" label="Label" value="Save" disabled />,
}, { level: 'atom' })

defineCases(component, cases, meta?)

Argument Type Notes
component string The display name shown in the sidebar. Its slug (kebab-case) forms the URL, e.g. TweakControl/c/tweak-control.
cases Record<string, Case> Keyed by display name; insertion order is preserved. Each value is either a simple render thunk or a tweaked case (see below).
meta.level HierarchyLevel? One of atom, molecule, organism, template, page (flow is set automatically by defineFlow). Drives sidebar grouping. Omit to leave it "unclassified" (sorted last). See Hierarchy.
meta.group string | string[]? Pages and flows only (surfaces): the information-architecture group — where the surface sits in the Exhibits mode's nav tree, as a path ('App/Settings/Billing' or ['App','Settings','Billing']). Omit to derive it from the case file's folder, then showcase config, then a default group. Distinct from area (which selects decorator chrome). Ignored for building-block levels. See Hierarchy and nav.
meta.area string? Free-form layout tag passed to the decorator so it can wrap this case in app chrome (nav/header/footer). Display Case mandates no vocabulary — the decorator interprets the value. Takes precedence over folder-based detection via sourcePath; omit to fall back to that (or to render bare). Note: area is a rendering concern (which chrome wraps the case); group is a navigation concern (where it sits in the tree). They may coincide but are separate fields.

Two shapes of case

A simple case is a thunk returning a React node:

Disabled: () => <TweakControl kind="text" label="Label" value="Save" disabled />,

A tweaked case declares typed controls and receives their resolved values:

Playground: {
  tweaks: { label: tweak.text('Save') },
  render: (t) => <TweakControl kind="text" label="Label" value={t.label} />,
},

See Tweaks for the full control set.

Order the default-landing variant first

Clicking a component in the sidebar navigates to its first case, so lead with the most exploratory variant — a tweaked Playground case, or a "do-anything" interactive demo (e.g. a stateful, clickable example). Keep isolated single-state variants (one Disabled, one With error) after it: those exist mainly for snapshots and visual-regression, not as the thing a reader should land on. Flow steps are the exception — order them in flow sequence (defineFlow below).

defineFlow(name, { steps })

For behavioural multi-step flows — a wizard, a sign-in sequence — use defineFlow. Each step is an ordered, individually addressable, snapshottable state. A step may declare preset tweaks, transitions to other steps, and wire its injected goto into a presentational view's callbacks so an in-step button advances the flow in place.

// src/components/sign-in-flow.case.tsx
import { defineFlow } from '@awarebydefault/display-case'
import { RequestLink, CheckEmail, SignedIn } from './sign-in-screens'

export default defineFlow('Sign-in flow', {
  steps: {
    'Request link': {
      transitions: ['Check email'],
      render: ({ goto }) => <RequestLink onSubmit={() => goto('Check email')} />,
    },
    'Check email': {
      transitions: ['Signed in'],
      render: ({ goto }) => <CheckEmail onOpen={() => goto('Signed in')} />,
    },
    'Signed in': { render: () => <SignedIn /> },
  },
})

Keep the views pure: a step wires goto to the view's callbacks; the view never imports navigation, so the same view is reused in the real route. goto(step, overrides?) re-enters the target step with optional preset tweak values. A flow whose steps declare no transitions is a static, walkable sequence. A flow is always at the flow level. See Hierarchy for how flows differ from regular cases.

defineFlow also accepts an optional group and area alongside steps — the same fields as meta.group and meta.area — so a flow can be placed in the Exhibits nav tree and wrapped in app chrome:

export default defineFlow('Checkout', {
  group: 'Shop/Checkout',
  area: 'app',
  steps: { /* … */ },
})

Typed step values. A bare step object has loosely-typed values. To read typed preset values (values.error as boolean), wrap the step in the flowStep helper, which infers the step's own tweak schema:

import { defineFlow, flowStep, tweak } from '@awarebydefault/display-case'

export default defineFlow('Sign-in', {
  steps: {
    'Check email': flowStep({
      tweaks: { error: tweak.boolean(false) },
      render: ({ values, goto }) => (
        <CheckEmail error={values.error} onVerify={() => goto('Signed in')} />
      ),
    }),
    'Signed in': { render: () => <SignedIn /> },
  },
})

goto/transitions targets are not key-checked at compile time — an unknown target renders the not-found step at runtime.

Authoring rules

  • Default-export the definition. A file with no valid default export (or one whose component is not a string) is skipped and reported as a load error; the rest still load.

  • No top-level side effects. Render functions are lazy. The server imports the module to build the manifest without rendering — so don't call hooks, fetch, or touch the DOM at module top level.

  • Give an interactive specimen a distinct per-case key. This is a foot-gun worth understanding. A controlled component needs a little wrapper that owns its state (function Demo({ initial }) { const [v, setV] = useState(initial); … }), and you'll reuse that one wrapper across several cases. But the browse chrome swaps cases in place — it re-renders one persistent root with root.render() and never unmounts (so theme/tweak changes don't flicker). React then sees the same <Demo> at the same position across cases and keeps its useState value instead of re-seeding from the new case's initial. Between cases whose props differ — a different selected id, or a disjoint set of options — the leaked value shows the wrong selection, or (if it isn't in the new options) no selection at all. Fix it by giving each case's wrapper a distinct key so React remounts it:

    function Demo({ options, initial }: { options: Opt[]; initial: string }) {
      const [value, setValue] = useState(initial)
      return <Toggle options={options} value={value} onChange={setValue} />
    }
    
    export default defineCases('Toggle', {
      // A bare <Demo …/> here would carry the previous case's value across the swap.
      Two:  () => <Demo key="two"  options={two}  initial="b" />,
      Five: () => <Demo key="five" options={five} initial="lg" />,
    })

    A tweaked Playground that re-seeds from a tweak follows the same rule — key it by the seeding tweak (key={pg-${t.count}}) so changing the tweak remounts with the new initial. A specimen rendered in only one case is safe (switching to any sibling case mounts a different element, which remounts it anyway). The interactive-cases-keyed structure check flags a stateful wrapper reused across cases with a missing key; waive a deliberate exception with a // display-case: allow-interactive-cases-keyed <reason> comment.

  • Compose freely inside a render. Layout wrappers, multiple instances, sample data — anything that returns a React node is fine.

  • One component (or flow) per file. Coverage tooling expects a <name>.case.tsx sibling for each component module.

  • Edits are picked up on save; reload to see them. The dev server watches case files and rebuilds on every change — including manifest-shape edits (case order, case/component names, hierarchy level, tweak schema). There is no in-page HMR, so reload the browser to pick up a rebuild. (The rebuild reads the manifest in a fresh subprocess because Bun caches ES modules by path for a process's lifetime; without that, an in-process re-import would return the stale module.)

Coverage

The coverage check fails if a showcased component module has no colocated *.case.tsx. Wire it into your lint or CI step to keep every component browsable. To exempt a non-showcasable module, add a comment anywhere in the component file:

// display-case: no-case this is an internal helper, not a visual component

See also

  • Runnable examples: examples/ — a plain case, a tweaks case, and a multi-variant case.
  • Configuration for roots globs that decide which files are discovered.

Clone this wiki locally