-
Notifications
You must be signed in to change notification settings - Fork 0
Writing Cases
🔄 Auto-generated mirror. Canonical source:
docs/writing-cases.md. Edit there — changes here are overwritten by CI.
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' })| 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. |
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.
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).
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.
-
Default-export the definition. A file with no valid default export (or one whose
componentis 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 withroot.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 itsuseStatevalue instead of re-seeding from the new case'sinitial. 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 distinctkeyso 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
Playgroundthat 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 newinitial. A specimen rendered in only one case is safe (switching to any sibling case mounts a different element, which remounts it anyway). Theinteractive-cases-keyedstructure check flags a stateful wrapper reused across cases with a missingkey; 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.tsxsibling 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.)
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- Runnable examples: examples/ — a plain case, a tweaks case, and a multi-variant case.
-
Configuration for
rootsglobs that decide which files are discovered.
Auto-generated mirror of the Display Case docs. Edit the repo, not the wiki.
Product documentation
- AI Agents
- CLI
- Configuration
- Documentation Panel
- Examples
- Hierarchy
- Quick Start
- Style Engines
- Testing
- Theming
- Tweaks
- Writing Cases
- Writing Placard Docs
Contributing (engineering)