Skip to main content

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) and libs/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)
ScenarioBase classDynamic kindHost
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 groupDynamicServicedComponent<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 input
  • protected 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:

  • _props is 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 _props field or a props setter — react to prop changes with effect/computed in 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.ts beside the component, exporting FooArgsSchema (Zod), FooArgs, and FooService.
  • 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 effect on 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:

  1. [component].spec.ts beside the component with regular behavior tests and a describe('dynamic', ...) block asserting: the create*Dynamic factory 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).
  2. A STORY_FIXTURES entry in libs/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. Keep minimalData truly minimal and make the probe assert 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".

#GotchaRule
0In-lib alias importNever import @stretched/stretched-components inside the lib — circular dep, runtime crash. Relative paths only.
1_props is computedAlways call it (_props()); derive with computed, not getters.
2No render-time validationDynamicConfigHost passes stored config.data raw — the Zod schema is builder-UI only. Guard defensively.
3Builder 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).
4Boolean name mismatchSchema/defaults/template must all use the same is-prefixed name — a mismatch silently reads the default.
5Don't override propsIt's a signal input() — use effect/computed to react; a manual setter silently breaks dynamic rendering.
6@for trackingAlways track $index on array props — user fields are duplicated/undefined and throw NG0955.
7Number arrays hold nullThe 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.
8App-DI tokensA dynamic component must not hard-require an app-provided token (NG0201 kills the whole story render). Every injected InjectionToken needs a degraded default factory.
9Service methods must set signalsUpdate the internal signal first, then notify — callback-only methods leave the reactive template stale.
10No backdrop-filter on scrimsA 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 (never index.ts).
  • Booleans prefixed is/has/can/should — schema fields, class members, locals.
  • Icon props typed EIcon, color props typed EColor (union-widen if needed, never bare string) — see Design System and Foundations.
  • reduce callbacks use prev/curr; full descriptive names outside lambdas.
  • Prefer display: grid over flex; all SCSS values from foundation tokens.
  • Stories: no inline styles (use a [component].stories.scss with tokens); don't restructure stories without asking.
  • Each component keeps a README.md documenting 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.