Appearance
008 — Generated meta registry + runtime slot catalog
Ticket: 86caz5kn3
Problem
parameters.slotPlayground.catalog is a full corpus descriptor inlined into every Playground story, and it grows quadratically with the component count. Measured on the production corpus (21 definitions, 42 story files, 23,371 lines):
| block | lines |
|---|---|
parameters.slotPlayground catalog | 12,682 |
componentMeta | 2,946 |
argTypes | 1,713 |
const registry | 364 |
../X component imports | 292 |
Angular moduleMetadata component entries | 170 |
| story bodies, args, meta shell, doc imports | ~5,204 |
54% of generated story output is one object, serialised 24 times per target.
The amplifier is collectRegistryComponents (targets-common/src/storybook/registry-components.ts:68):
ts
const names = open ? [...importable] : [hostName, ...visited];A slot with no accepts: defaults to ['any'] (common/src/plugins/slots.ts:55), and open is set transitively during the BFS, so one open slot anywhere in the accept-closure makes the closure the whole corpus. Button declares three open slots; Dialog accepts only its three regions but inherits the fallback because DialogBody/Footer/Header declare none.
The cost that matters is churn, not disk. Commit 89b7761741e added three components and wrote +124/127 lines into each of 12 pre-existing story files — ~1,505 lines of pure churn — and the blast radius grows with N: at 21 definitions there are 24 stories carrying a catalog, so component #22 would touch ~24 files.
Shape
Three generated artifacts replace the inlined catalog.
1. Per-component meta module — componentMeta moves out of the story and is exported.
ts
// libs/ui-react/generated/Button/Button.meta.ts
import type { ComponentMeta } from '@stuido/storybook/shared';
export const buttonMeta: ComponentMeta = { name: 'Button', inputs: [...], slots: [...], ... };2. One corpus module per target — shared/story-catalog.ts.
ts
import type { SlotComponentRegistry } from '@stuido/storybook/react';
import { Avatar } from '../Avatar/Avatar'; /* … 20 more … */
import { avatarMeta } from '../Avatar/Avatar.meta'; /* … 20 more … */
/** name → component. Cannot be derived — needs the real imports. */
export const SLOT_REGISTRY = { Avatar, Badge, Button, ... } as unknown as SlotComponentRegistry;
/** name → meta. The catalog's only input. */
export const COMPONENT_METAS = { Avatar: avatarMeta, Badge: badgeMeta, ... };3. The story — one call replaces the literal.
ts
import { buttonMeta } from './Button.meta';
import { COMPONENT_METAS, SLOT_REGISTRY } from '../shared/story-catalog';
import { slotPlaygroundParam } from '@stuido/storybook/shared';
parameters: {
slotPlayground: slotPlaygroundParam('Button', COMPONENT_METAS),
},Angular is identical apart from the import paths and SLOT_REGISTRY holding classes.
Expected result
Generated story + meta output 23,371 → ~10,300 lines (−56%). Angular's Button story 1233 → ~210, plus a ~110-line button.meta.ts.
Adding component #22: existing story files 0 lines, 0 files touched; shared/story-catalog.ts × 2 gains 2 imports + 2 entries each (+8 lines); the new component emits its own ~640.
argTypes (1,713 lines) is out of scope — see "Deliberately not in scope".
Decisions
Corpus-wide catalog is behaviourally identical to the per-host closure, not a trade-off. The manager panel reads the catalog exactly two ways:
catalog[name]lookups, andslot.open ? Object.keys(catalog).sort() : [...slot.accepts](storybook/src/manager/panel.tsx:191). Anopenslot already implies a full-corpus closure, so the first branch is unchanged. For a narrow closure the extra keys are unreachable, because the closure is accept-closed — the BFS pushes the accepts of every slot of every visited component (registry-components.ts:60-65), so no closure member can accept a non-member. Each entry'sacceptsarray is therefore byte-identical to today's. This is the load-bearing claim of the whole plan; it gets an explicit spec, not a comment.The catalog builder moves to
@stuido/storybook/sharedand is generate-time deleted.buildSlotPlaygroundParam+ theSlotPlaygroundParam/SlotCatalogEntry/SlotCatalogInput/SlotCatalogSlottypes move fromtargets-common/src/storybook/slot-catalog.tsintostorybook/src/shared/slot-playground.ts, re-signatured overRecord<string, ComponentMeta>. The manager panel already imports the types across that boundary (panel.tsx:36-40) and@stuido/targets-commonis already a runtime dependency of@stuido/storybook(packages/storybook/package.json:69-71), so no new package edge appears. The two per-targetir-reader/slot-playground-param.tslowerers and the generate-timeslot-catalog.tsare deleted outright.collectRegistryComponentsis deleted, not rewired. It exists only to keep the emitted catalog and registry small. With both corpus-wide, the closure walk, theopenfallback and thecatalogNamesfilter have no consumer. Its remaining job — gating whether a story gets a registry at all — collapses tohasControllableSlot(ir), which isbuildSlotPlaygroundParam's own gate (slot-catalog.ts:210-211). This also dissolves a gap that would otherwise block the plan: the collector seeds fromir.usage[].slots(registry-components.ts:44-51), andComponentMetacarries nousagearray — onlycanonicalUsageandcodeExample. A corpus-wide catalog needs no seed.The registry and Angular's
moduleMetadata.importsstay generated, hoisted. They hold real class/function references, so they cannot be derived from meta.../Ximports narrow to the usage-subtree components only — those are spelled lexically by the Docs and Matrix templates and must stay importable. The registry-closure half of that import list, theconst registryblock, and the 21moduleMetadataclass entries all collapse intoshared/story-catalog.ts.Angular's
moduleMetadata.importsbecomes[...STORY_COMPONENT_IMPORTS, <doc components>], which needs a spread.AngularStoryEmitTree.moduleImportsisstring[]printed as plain identifiers, so this needs a representation for "spread this identifier" rather than a raw string smuggling.... TheSuiSlotDirectivegate (storybook/ir-reader/collect-imports.ts) is untouched — it still walkstree.storiesfor the binding with the corpus-level fallback for runtime markup.No new emit-tree kind for the catalog reference.
StructuredValuealready carries{ kind: 'expression'; source: string }viaArgValue(angular/src/emit/structured-values.ts:25,97), printed throughparseExpressionatstorybook/printer/structured.ts:40. The playground parameter becomes{ kind: 'expression', source: "slotPlaygroundParam('Button', COMPONENT_METAS)" }in both targets.AngularStoryKind'sslotPlayground?: StructuredValuefield keeps its type.PropInfo.defaultis a string; the runtime builder coerces by control kind.ComponentMetastringifies defaults (storybook/src/shared/types.ts:42— anumberinput readsdefault: '0', abooleanreadsdefault: 'false'), whileSlotCatalogInput.defaultisstring | number | boolean(slot-catalog.ts:48). The builder coerces:boolean→=== 'true',number→Number(...)with aNaNguard, else verbatim. This is a lossy round-trip the generate-time path does not have, so it gets a spec per control kind. Rejected alternative: wideningPropInfo.defaultto the union — it changes the emitted meta bytes of every component with a non-string default and touchesPropTablerendering, for a three-line coercion.isControllableTypeis reused;getSimpleInputsgets a meta twin.isControllableType(typeSource: string)(targets-common/src/storybook/simple-inputs.ts:20) takes a plain string and is imported as-is.getSimpleInputsreadsComponentIRfield names (typeSource) thatComponentMetaspells differently (type), so the runtime builder carries its own small twin overComponentMetarather than a structural widening that would fail on the field rename. The slot capability predicates (isOpenSlot,isTextCapable,isControllableSlot—targets-common/src/storybook/slot-capability.ts) read onlynameandaccepts, so they are widened to a minimal structural parameter type ({ name: string; accepts: readonly string[] }) and shared by both callers. That mirrors the existingSlotFillSeedprecedent, which already mirrors a runtime-lib shape structurally to avoid a package dependency.Determinism moves from a file property to a runtime property for the catalog. Every sort in the builder (component names, inputs, slots, accepts; a select's options keep literal-union order) is kept and stays a plain code-unit compare. The generated files remain byte-stable; what is no longer reviewable in a diff is the resolved catalog. Accepted, because the catalog is derived data with a single consumer, and the per-entry equivalence claim in Decision 1 is pinned by a spec that builds the catalog from metas and deep-equals it against the current generate-time output for the whole sandbox corpus.
Metas stay out of the root barrel.
shared/story-catalog.tsand the*.meta.tsmodules are story-support artifacts, not public API. Precedent:shared/enumerate.tsis emitted and never exported (angular/src/component/angular-component.ts:386).The manager channel payload is unchanged, and that is not what this fixes. Each story still transmits the full catalog to the Compose panel — inherent to the panel living in the Storybook manager (
panel.tsx:10-19). What shrinks is generated source, git churn, and preview-bundle size: one catalog computed once instead of 24 literals bundled per target.
Files
| Step | File | Change |
|---|---|---|
| 1 | storybook/src/shared/slot-playground.ts (new) | SlotPlaygroundParam types + slotPlaygroundParam(host, metas); meta-based getSimpleInputs twin + default coercion |
| 2 | storybook/src/shared/index.ts | export the builder + types |
| 3 | targets-common/src/storybook/slot-capability.ts | widen predicates to { name, accepts } |
| 4 | targets-common/src/storybook/slot-catalog.ts (delete) | moved to step 1 |
| 5 | targets-common/src/storybook/registry-components.ts (delete) | replaced by hasControllableSlot(ir) (co-located with the capability predicates) |
| 6 | targets-common/src/index.ts | drop the removed exports, add hasControllableSlot |
| 7 | storybook/src/manager/panel.tsx | import the types from ../shared instead of @stuido/targets-common |
| 8 | angular/src/storybook/story-catalog-file.ts (new) | emit shared/story-catalog.ts (source text, on the component/context-file.ts precedent) |
| 9 | react/src/storybook/story-catalog-file.ts (new) | same for React |
| 10 | angular/src/storybook/printer/print-meta-file.ts (new) | print <kebab>.meta.ts from the existing componentMeta StructuredObjectLiteral |
| 11 | react/src/storybook/printer/print-meta-file.ts (new) | same, <Pascal>.meta.ts |
| 12 | angular/src/storybook/angular-storybook.ts | emit the meta file per component + the one corpus file after the loop |
| 13 | react/src/storybook/react-storybook.ts | same |
| 14 | angular/src/storybook/printer/print-story-tree.ts | drop the componentMeta const + the registry const; import both instead |
| 15 | react/src/storybook/printer/print-story-tree.ts | same |
| 16 | angular/src/storybook/ir-reader/collect-imports.ts | ../X imports narrow to usage components; add the shared/story-catalog + ./x.meta imports |
| 17 | react/src/storybook/ir-reader/collect-imports.ts | same |
| 18 | angular/src/emit/story.ts + printer/print-story-tree.ts | moduleImports entry kind that prints as a spread |
| 19 | {angular,react}/src/storybook/ir-reader/slot-playground-param.ts | replaced by an expression-kind StructuredValue |
| 20 | {angular,react}/src/storybook/ir-reader/build-story-tree.ts | drop collectRegistryComponents; gate on hasControllableSlot |
| 21 | storybook/src/shared/slot-playground.spec.ts (new) | Decision 1 equivalence over the whole sandbox corpus; Decision 6 coercion per control kind |
| 22 | {angular,react}/src/storybook/*-storybook.spec.ts, collect-imports.spec.ts, story fitness suites | update expectations |
| 23 | targets-common/src/storybook/slot-catalog.spec.ts (delete), registry-components.spec.ts (delete) | coverage moves to step 21 |
| 24 | docs/architecture.md | rewrite the "Storybook Playground slot-fill contract" catalog paragraphs |
Verification
pnpm nx run-many -t test --no-tui --skip-nx-cachegreen. Expect the known silent run-many flake on@stuido/react:test/@stuido/angular:test— re-run standalone before investigating.pnpm nx run @stuido/sandbox:generate --no-tui --skip-nx-cacheclean, thenpnpm nx run design-system:generatefor the production corpus. Commit the regeneratedlibs/ui-react/generated/+libs/ui-angular/generated/.- The equivalence claim measured, not reasoned. Before landing step 4, snapshot every current
parameters.slotPlaygroundvalue from the production corpus to JSON; after, deep-equal the runtime builder's output against it per host. Divergence inaccepts,open,textordefaultis a bug in this plan, not in the snapshot. - Churn measured. Add a throwaway 22nd definition to the sandbox, regenerate, and confirm
git statuslists only the new component's files plus the twoshared/story-catalog.ts. Any pre-existing story file in that list means a per-story literal survived. - Both Storybooks, in the browser (sandbox React :7006 / Angular :7007). The Compose panel must offer the same component list per slot as before for a narrow host (
Panel,Chip) and for an open host (Button), nest to depth ≥ 2, seed correctly, and round-trip a?sf=link. Console clean on reload in both — in particular no AngularNG0303/NG0304, which is the failure mode if themoduleMetadataspread drops a class. NODE_OPTIONS=--max_old_space_size=8192 npx tsc --noEmitover the generated libs' tsconfig — the generated*.meta.tsandshared/story-catalog.tsare new module boundaries and specs in this tree are never type-checked by the build.
Deliberately not in scope
argTypesderivation (1,713 lines). Exporting the metas makesargTypes: argTypesFromMeta(componentMeta)nearly free, but it removes the resolved control kind,optionsarray andtable.categoryfrom reviewable output — so a change to the type→control mapping inside@stuido/storybookwould alter every Controls panel with zero diff in any generated file. Worth its own ticket with that trade-off stated.- Narrowing the
accepts: anydefault. Once the catalog is hoisted, closure size no longer drives file size, so what an open slot should offer in the Compose panel becomes a UX question rather than a codegen one. - The per-story manager channel payload (Decision 10).