ADR 0009. The default export is a demo, not the component
Every registry file exports a named component with its real required props and a separate zero-argument demo as its default export.
Status
Accepted. 2026-09-03. Verified against the pipeline fixture at
registry/bases/base/status-pill.tsx, which renders at
/view/base/base-lyra/component/status-pill and typechecks against the emitted
index.
Context
One file has to satisfy three readers who want incompatible things from it.
The preview route wants something it can render with no arguments. The
isolated /view page resolves a registry entry, awaits the dynamic import and
renders (await entry.component()).default as <Preview />. No props are passed
at all. There is nowhere for a prop to come from: the URL carries a base, a
style, a kind and a name, and nothing else. Screenshot tooling drives the same
URL.
The specifications want required props with no defaults. StatusPillProps
documents status as "Required. There is no neutral default and no 'unknown'
level". That is not an oversight to be tidied away with a default value. A
component that defaults a clinical status has asserted something about a reader
that nobody decided to say, which is the failure
ADR 0003 and
Clinical status semantics exist to
prevent. RangeBar, ResultCard and Value are the same shape: the required
props are required because the product owns the answer.
The consumer wants the file. Under ADR 0002
the unit of distribution is the source text, so whatever this file contains is
what npx shadcn add writes into somebody's repository. The page shows the same
text: <ComponentSource> resolves a name against the registry and has no code
prop, deliberately, so a page cannot display a tidied version of a file that
ships untidy.
There is also a typing constraint that decides the question on its own. The
generated index types the loader as
(() => Promise<{ default: ComponentType<Record<string, unknown>> }>) | null,
registry/bases/** is typechecked under strict, and Record<string, unknown>
is not assignable to an interface with a required status: ClinicalStatus. A
default export that is the component does not compile.
Decision
Every file under registry/bases/<base>/ has exactly three public exports, in
this order:
export interface StatusPillProps { /* … */ }
export function StatusPill(props: StatusPillProps) { /* the real component */ }
export default function StatusPillDemo() {
return <StatusPill status="watch" label="Example measurement" />
}- The props interface is a named exported interface, never an inline object
type and never
ComponentProps<typeof Something>. It is what the generated props table is built from, and an anonymous type produces an empty table. - The component is a named export carrying its real required props. Nothing is made optional to suit the preview.
- The default export is a zero-argument function declaration named
<Pascal>Demo. Neverexport default StatusPill, and never a default export that takes props.
Consequences
- The demo is public, reviewed, shipped code. The source block on the page
shows the whole file and
shadcn addwrites the whole file, so the demo is held to the same standard as the component beside it: no placeholder strings, no invented measurements, and only the synthetic example data of ADR 0012. - The generated index typechecks. A zero-argument function is assignable to
ComponentType<Record<string, unknown>>, so the emittedcomponent: () => import(…)compiles without widening the type inscripts/build-registry.mts. That is the difference between a preview surface that works and one whose generator has to be loosened to accommodate it. - One preview per component, and it is not configurable. The
/viewroute can only ever show the demo, because it has no channel through which to send anything else. Where a component has a second state, such as the empty value, theurgentlevel or the long label, that state is a file underregistry/examples/, addressable at/view/base/base-lyra/example/<name>and named by<ComponentPreview>. This is the constraint that makes the examples directory necessary rather than decorative. - The demo is a second thing to keep true. When the props change, the demo changes, and nothing checks that it still demonstrates the component's interesting case. It is reviewed as part of the file; there is no gate.
- A component whose demo is embarrassing is telling you something. If the
only honest zero-prop rendering of a component is a shrug, either the component
needs a state it does not have, or the interesting behaviour belongs in an
example. Both are worth knowing before the page reaches
shipped.
Because the demo ships and is copied first, four rules hold it to a common standard across the registry.
- A demo renders the primary happy case and emits no development warning. A
warning fires when a component is asked to render something it is built to
refuse or to protest, so a demo that trips one is telling every consumer who
copies it to start from the case the component objects to. Absence, under-data
and refused-input cases are still worth showing, so they live in
registry/examples/, where a page presents them deliberately and the warning is the lesson rather than an accident. The exception is what "happy case" means: it is the case that demonstrates the component's actual claim, not the prettiest frame.Surfacerenders its demo over a backdrop chosen to be hard on a translucent surface rather than kind to it, and theBACKDROP_TILEScomment insurface.tsxargues why, because a translucent surface that only ever sits on a gentle ground has not been shown doing its job. There is one true exception to the no-warning half, and it isScoreDial. Its demo passesbands={[]}and so trips the "was given no bands" warning inscore-dial.tsxon purpose, because ADR 0012 forbids a shipped default export from distributing invented band geometry into/r/score-dial.jsonand into everyshadcn addcopy. The absence warning is the honest output there, so the demo keeps it, and the supplied-band case lives inregistry/examples/instead, where an example file carries no catalogue row and appears in no/rpayload and can therefore show real geometry. Read this as no licence to invent bands: the fix for the warning is an example, never a band set added to the demo. - A demo carries a one-line orienting footnote only when the component portals out of the preview frame. A portal is the one case where what the reader sees is not where the component rendered, so the footnote tells them the dialog or sheet they are looking at came from the button above it. A component that renders in place needs no such note, and adding one to every demo would train readers to skip the note in the one demo that needs it.
- A demo never prints prop syntax as rendered text. The source is shown by
<ComponentSource>and the props by the generated table, so a caption that spells a prop states a third time what two generated surfaces already state, and it states it in a place that cannot stay in step with the interface. Label a shape in the rendered preview with plain words, not with the prop that produced it. - A demo uses the registry components it depends on rather than hand-rolling a copy of one. The demo is the file a consumer reads first, so a hand-rolled stand-in for a component the file already declares as a dependency teaches the consumer to bypass the very component the demo sits beside. Import the real one.
Alternatives considered
Export the component as the default and give every prop a default value.
Rejected. The defaults would have to include a clinical status, and there is no
defensible one: steady is a verdict, unknown is not a fifth level, and
omitting the prop entirely would make the component render an assertion nobody
made. The rule that opsinjs never assigns a clinical level is the reason this
system is worth shipping, and it is not negotiable for the convenience of a
preview route.
Let /view pass props from the query string, or from a sidecar JSON file.
Rejected on two grounds. The props would be authored somewhere that is not
reviewed alongside the component, so a preview could drift from the interface it
claims to demonstrate without a compile error. And the encoding would become a
format we have to version: scripts/capture-registry.mts, every documentation
page and every hand-typed URL would have to agree on how a ReferenceRange
serialises into a query string.
A sibling file, status-pill.demo.tsx, beside the component. Rejected
because of how the generator walks the tree. findBuilt() registers every
direct child .ts/.tsx file under registry/bases/<base>/ as a component, so
the demo would acquire its own index entry and its own live
/view/base/base-lyra/component/status-pill.demo route with no catalogue row
behind it. Moving it into a subdirectory is worse: the walk is not recursive, so
the file would be invisible and the failure silent.
Two exports, with the demo as a named export the route looks up by
convention. Rejected as a private protocol. default is the one name a dynamic
import can rely on without the route and the generator agreeing on a naming
scheme, and a naming scheme that only the preview surface knows about is the kind
of thing that survives until somebody renames a file.
Revisiting this
Revisit when a screenshot requirement cannot be expressed as a demo. Capturing
one component at all four status levels in a single frame is the obvious
candidate. The answer there is probably a screen under registry/screens/, which
already exists for exactly that job, rather than teaching /view to accept
props. If /view does ever learn to pass props, this record is what has to
change first, because the typing of the generated index follows from it.
Last read through against the system on 2026-09-03. Due for review every 12 months; expiry is reported by pnpm run check:freshness.
ADR 0007. Two root layouts, and no app/layout.tsx
The isolated preview surface owns its own document, so the application has two sibling root layouts and no shared one. That is awkward for the 404 page.
ADR 0010. Shared code ships inside every registry item
One substrate module, appended to every component's files list with its real content, so the same import path resolves in this repository and in a consumer's.