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-prefixedindex.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.