Component Library Guide
How to author a component in libs/stretched-components so it works everywhere: in templates, in the Page Builder, and inside stored stories rendered by the Story Engine.
See also: Dynamic Component System · Design System and Foundations · Testing
Source of truth: this page condenses
libs/stretched-components/src/components/CLAUDE.md(the full authoring guide) andlibs/stretched-components/src/dynamic-host/README.md(the dynamic-kind spec). When those files change, update this page.
The hard rule: every component is dynamic
Every component not in organisms/ must extend one of two dynamic base classes (defined under libs/stretched-components/src/dynamic-host/). This lets every component be used two ways:
- In templates —
[props]="..."or[service]="..." - From JSON/config — rendered by a dynamic host from stored data (see Dynamic Component System)
| Scenario | Base class | Dynamic kind | Host |
|---|---|---|---|
| Receives data and renders it (most cases) | DynamicComponent<T> | kind: 'props' | DynamicConfigHost |
| Interactive/stateful, no form field (toggle, legend) | DynamicServicedComponent<S> | kind: 'service' | DynamicConfigHost |
Form field — a Field<any> owned by a form group | DynamicServicedComponent<S> | kind: 'serviced-input' | DynamicInputHost |
Exceptions: organisms (page-level composites) and pure infrastructure components (e.g. DynamicConfigHost itself). Everything else — no exceptions. When you create a brand-new component, ask the user whether it should be an exception before writing it.
The props / _props signal pattern
Components take a single props input, not individual @Input() fields. Both are inherited from DynamicComponent<T>:
readonly props = input<T>()— the signal inputprotected readonly _props = computed(() => ({ ...this.defaults, ...this.props() }))— defaults merged under the incoming value
The component only declares a module-level defaults const typed Required<FooProps>, so _props() is always fully defined:
const defaults: Required<FooProps> = { label: '', variant: 'default' };
@Component({ ... })
export class Foo extends DynamicComponent<FooProps> {
protected readonly defaults = defaults;
}
Key rules:
_propsis a computed signal — always call it:_props().label, in templates and TS alike.- All schema fields are optional; required values live in
defaults. - Derive state with
computed, never plain getters. - Never declare a manual
_propsfield or apropssetter — react to prop changes witheffect/computedin the constructor instead.
The service pattern (bidirectional state — no output())
When a child must push state back to a parent (what would traditionally be an output()), use a service class instead:
- Service lives in
[component].service.tsbeside the component, exportingFooArgsSchema(Zod),FooArgs, andFooService. - All state is private writable signals exposed via
.asReadonly(). - Mutation methods are the only write interface — and each must update its own signal first, then fire any callback (a callback-only method leaves the template stale).
- The child extends
DynamicServicedComponent<FooService>(readonly service = input.required<S>()) — no_props, no outputs. - The parent creates the service, syncs data into it via an
effecton its own_props, and reads interactive state back through the service's readonly signals.
Required exports
Every dynamic component file must export (names must be component-prefixed — index.ts re-exports everything via export *, so bare names collide with TS2308):
export const FooPropsSchema = z.object({ ... }); // Zod — single source of truth
export type FooProps = z.infer<typeof FooPropsSchema>; // never hand-write the type
export const FOO_DYNAMIC_KEY = 'foo'; // stable — NEVER rename once stored
export type FooData = FooProps; // the JSON/Firebase-safe shape
export function createFooDynamic(data: FooData): DynamicConfig<FooData> {
return { component: FOO_DYNAMIC_KEY, kind: 'props', data };
}
Variations by kind: kind: 'service' exports FooArgsSchema + createFooServiceDynamic(service) returning DynamicServiceConfig; kind: 'serviced-input' exports FooArgsSchema, FooData, and a factory returning DynamicInputConfig (the Field is injected at render time by DynamicInputHost, never stored). Full spec: libs/stretched-components/src/dynamic-host/README.md.
The Zod schema also drives the builder's field UI — prefer nested z.object groups over flat prefixed names; unions/tuples/literals/dates are not builder-editable.
Registration happens in the registry sub-files under libs/stretched-components/src/dynamic-host/registry/ (props-components.ts, service-components.ts, input-components.ts — never edit all-dynamic-components.ts directly).
Conversion tracker
libs/stretched-components/dynamic-conversion-tracker.json is the authoritative record of every component's dynamic status (converted / pending / exceptions). Keep it in sync when converting, adding, or excepting a component. pending is tracked debt, not a bug — do not mass-convert unprompted; convert only when already touching a component or explicitly asked.
Compose, don't reimplement
Before writing raw HTML/CSS for anything interactive, check whether a library component already covers it: sc-number-input for $-prefixed numbers, sc-input for labeled/validated text, NumberInputService.formatFull() / KpiCard for formatted currency, SegmentedControl for chip rows, the Modal service for confirmations. If your CSS duplicates an existing component, stop and compose instead — the service-wiring "ceremony" buys formatting consistency, token use, and accessibility in one shot.
Same instinct for styling values: check src/foundations/ before hardcoding anything — see Design System and Foundations.
Testing requirements
Full picture in Testing; the per-component contract is:
[component].spec.tsbeside the component with regular behavior tests and adescribe('dynamic', ...)block asserting: thecreate*Dynamicfactory shape, the schema accepting{}and valid data, and the registry entry (PROPS_COMPONENTS/SERVICE_COMPONENTS/INPUT_COMPONENTS— imported by relative path, never the public alias).- A
STORY_FIXTURESentry inlibs/stretched-components/src/dynamic-host/registry/story-context-coverage.spec.ts, which renders every registered component through a stored story in three scenarios: empty{}data, worst-case builder noise generated from the schema, and a minimal fixture. Its completeness test fails until your key is added. KeepminimalDatatruly minimal and make theprobeassert real rendered content, not just "didn't crash".
Runtime gotchas (condensed)
These are the silent or misleading failures that have actually burned this codebase. Full write-ups with code samples are in libs/stretched-components/src/components/CLAUDE.md § "Runtime gotchas".
| # | Gotcha | Rule |
|---|---|---|
| 0 | In-lib alias import | Never import @stretched/stretched-components inside the lib — circular dep, runtime crash. Relative paths only. |
| 1 | _props is computed | Always call it (_props()); derive with computed, not getters. |
| 2 | No render-time validation | DynamicConfigHost passes stored config.data raw — the Zod schema is builder-UI only. Guard defensively. |
| 3 | Builder emits {} items | "Add item" pushes an empty object immediately. Filter incomplete items before iterating/aggregating — one {} turns a reduce into NaN, and a throw inside a chart's draw effect kills change detection so the builder looks dead. Serviced-input args schemas must keep array item fields optional (factories parse() their args). |
| 4 | Boolean name mismatch | Schema/defaults/template must all use the same is-prefixed name — a mismatch silently reads the default. |
| 5 | Don't override props | It's a signal input() — use effect/computed to react; a manual setter silently breaks dynamic rendering. |
| 6 | @for tracking | Always track $index on array props — user fields are duplicated/undefined and throw NG0955. |
| 7 | Number arrays hold null | The builder's number-list editor stores null for empty inputs (old configs may hold strings). Filter with typeof value === 'number' before math — d3 compares strings lexicographically. |
| 8 | App-DI tokens | A dynamic component must not hard-require an app-provided token (NG0201 kills the whole story render). Every injected InjectionToken needs a degraded default factory. |
| 9 | Service methods must set signals | Update the internal signal first, then notify — callback-only methods leave the reactive template stale. |
| 10 | No backdrop-filter on scrims | A full-viewport backdrop blur re-rasterizes the entire still-painted app on every repaint while the overlay is open — constant dropped frames that profile as GPU paint, not JS (settings dialog, 2026-07-30). Modal/overlay backdrops are flat var(--color-overlay) scrims only. |
Conventions quick reference
- Selectors
sc-*; one kebab-case folder per component; files named after the component (neverindex.ts). - Booleans prefixed
is/has/can/should— schema fields, class members, locals. - Icon props typed
EIcon, color props typedEColor(union-widen if needed, never barestring) — see Design System and Foundations. reducecallbacks useprev/curr; full descriptive names outside lambdas.- Prefer
display: gridover flex; all SCSS values from foundation tokens. - Stories: no inline styles (use a
[component].stories.scsswith tokens); don't restructure stories without asking. - Each component keeps a
README.mddocumenting props as a typed block + prop table, including per-item optionality for array props. - When defaults, edge cases, naming, or styling are unclear — ask before writing.