Skip to main content

Story Engine

See also: Data Model · Page Builder · Story Categories · Dynamic Component System · Context and Bindings

A story is the unit of content in Stretched: a Story document rendered live for every user. This page covers the Story shape, the runtime renderer (StoryTeller), input vs. derived variables, gating (sectionGate), cross-story dependencies, and the two persistence seams — the catalog (what users read) and the store (what admins write).

Where the shipped stories live. The stories users play today are hand-authored runtime Story objects in TypeScript: components/story-teller/foundation-stories.ts (TIME_STORY, WAGE_STORY) and cost-stories.ts (31 more, via a story() factory), combined as ALL_STORIES and wired into STORY_CATALOG_SOURCE. A separate, compact catalog (stories/*.jsoncatalog.json) is an authoring-only artifact — its /stories/map dependency-graph viewer was removed 2026-07-29, there is no compiler between the two, and editing the compact files changes nothing users see. See components/story-teller/CLAUDE.md.

The public surface & the paid-tier gate (2026-07-21; paid gate 2026-07-25). The web /stories page renders the runtime catalog on the lib's sc-story-map (nodes from Story.dependsOn). Access policy lives in @stretched/types story/story-access.tsFREE_STORY_NAMES = time + wage are playable by anyone, and everything deeper needs the stories.deep ACL permission (any paid tier — canPlayStory(name, claims); it is a CONTENT permission, so the admin role alone does not unlock it, see Auth and Users). Without it (signed out OR the free Minutes tier) the deep tiers are redacted: the map's blockedFromTier input collapses everything behind the line into one short tier of anonymous placeholders at a fixed decorative count (blockedPlaceholderCount, default 6) — neither titles, tier structure, nor the real story count reach the DOM — under a host-projected paid-tier upsell overlay (signed out → the sign-in dialog via LoginDialogService; free tier → /account plans). Deep links stay guarded: storyAccessGuard on /stories/:name redirects blocked accounts to /stories?locked=<name>, which lands with the paid banner open. With stories.deep, the map passes isAllUnlocked and marks stories with saved entries (UserStoryEntriesService.completedStoryNames()) as completed. The client reads claims through the web AuthService.accountClaims() (typed via claimsFromToken); this is a UX gate only — story content is public JSON.

The Story definition

Types: libs/stretched-types/src/story/story.ts + story-content.ts (see Data Model for the full ERD).

type Story = {
name: string; // doc id AND the variable namespace ($story.name refs)
version: string;
description: Record<string, DynamicComponent>; // locale key → display component
categories: StoryCategory[]; // browse taxonomy — see [[Story Categories]]
featureFlag: string;
releaseDate: Date; // Date in-app; Timestamp/ISO on the wire
dependsOn?: string[];
sections: StorySection[]; // madlib strings: "I earn ${income} per month"
variables: Record<string, StoryVariable>; // { type, validators?, input? } OR { type, formula }
root?: DynamicComponent; // builder-authored component tree
};

Two authoring styles coexist:

  • Madlib sections — each StorySection.section is a template string; ${key} tokens (parsed by components/story-teller/parse-template.ts) embed the variable's input component inline in prose.
  • Root tree — a builder-authored component tree (root), nested children living inside data['children']. This is what Page Builder emits.

The stored shape deliberately has no kind discriminator — the component registry is the single source of truth for kinds (Dynamic Component System).

StoryTeller — the runtime renderer

libs/stretched-components/src/components/story-teller/story-teller.ts (<sc-story-teller [story] [locale] [override]>):

  1. Locale merge — an optional NonEnglishStoryOverride is applied first (mergedStory); everything downstream reads only the merged story.
  2. Variable sync — an effect calls ApplicationContextService.syncStory(story.name, declarations), (re)declaring each variable's signal, field, and validators into the shared context (Context and Bindings). That is what gives embedded inputs their fields, live validation, and two-way binding.
  3. Sections — each section string is split into text and ${key} parts. A key whose variable has a serviced-input component becomes a DynamicInputHost with binding: { story: story.name, name: key }; a props component renders via DynamicConfigHost; unknown keys/service entries degrade to visible text rather than crashing.
  4. Root treeconfigFromStored(root, registry) reattaches kind, then routes to the right host. A bad stored doc warns and renders nothing — it must never kill the page.

Reference definitions to learn from: foundation-stories.ts / cost-stories.ts (the shipped stories), plus value-of-time.definition.ts and growth-dashboard.definition.ts alongside the component.

Input vs. derived variables — and the computed-value paradigm

StoryVariable is one of two kinds, discriminated by which field is set:

  • Input (entered){ type, validators?, input }. The user fills it; it becomes a writable signal + Field (validators, live errors, two-way binding). Persisted.
  • Derived (computed){ type, formula }. Its value is COMPUTED from the formula over other variables ($name = same story, $story.name = another; decimal-exact — see Expressions and Math). syncStory registers it via ApplicationContextService.declareDerived, a reactive linkedSignal that resolves through { $ref } like any variable, so a KPI card can display it. Fail-soft to undefined when an input is missing or the result is non-finite (no NaN in the UI). Never persisted.

Paradigm — store inputs, re-derive everything else. Derived values are never written to the user doc; they recompute on the client from the formula (which lives in the published story content) plus the saved inputs. Enforced in storage/user-story-entry.ts (storyEntryFromContext skips variable.derived; seedContextFromStoryEntry skips declaration.formula). It is a deliberate cost choice: tiny storage, free client compute. Do not add derived values to persistence.

Cross-story dependencies

A cost story converts money → hours with cross-story refs ($wage.hourlyNet), which resolve only when the dependency stories are declared in the shared context WITH their inputs. On opening a story, hydrateStoryDependencies (storage/story-hydration.ts) walks dependsOn (transitively, dependencies-first) and seeds each dependency's saved inputs so those derived values recompute. The web /stories/:name page (story-view.component.ts) calls it; mobile (the UserStoriesService facade) does not yet. storyDeclarations(story) (same file) is the shared "variables → context declarations" mapping — it passes formula through, which is what makes derived variables register as computed.

sectionGate — conditional show/hide + unlock gating

libs/stretched-components/src/components/section-gate/section-gate.ts (kind: 'props', key sectionGate) directs users through a flow. Its conditions are expressions from the safe evaluator (Expressions and Math), evaluated live against the context so gates flip as bound inputs write values:

PropBehavior when falsy
showWhenthe gate renders nothing at all
unlockWhenchildren stay unmounted; a locked box shows reason instead
reasontemplate string — ${key} tokens show the variable's live value
storythe namespace unqualified $refs and bare $complete() resolve against

Expressions get a $complete(story?) helper — true when every variable in the story passes its validators — so "unlock the results at the end" is just unlockWhen: "$complete()". An empty condition is met; a malformed one fails open (renders content, warns once): a half-authored story never bricks the page. An unknown/empty story is never complete, so a gate on an unloaded story stays shut rather than flashing open.

Catalog vs. store — the crucial split

Both seams live in libs/stretched-components/src/dynamic-host/storage/ (full write-up: storage/story-store.md). They share the Story type but serve opposite lifecycles — don't conflate them:

Catalog (read)Store (write)
Whoevery user, at runtimeadmins, while authoring
Whatpublished storiesdrafts
SeamSTORY_CATALOG_SOURCEStoryCatalogService (storage/story-catalog.ts)STORY_STORE token, StoryStore interface (storage/story-store.ts)
Backingthe bundled ALL_STORIES array today (app.routes.ts provides () => ALL_STORIES on /stories — the hand-authored runtime stories in foundation-stories.ts + cost-stories.ts), CDN fetch laterapp: StoryStoreService (Firestore stories/{name}, apps/stretched/.../core/services/story-store.service.ts); Storybook: LocalStoryStore
Defaultempty catalog — nothing crashes unwiredLocalStoryStore (in-memory)

Story definitions are content, not user data: versioned, cacheable JSON files, not a Firestore read per page load. Only user answers (UserStoryEntry) and admin drafts touch Firestore — see Firestore and Security Rules and Data Model.

  • StoryCatalogService.load() is idempotent (concurrent calls share one fetch); reload() forces a re-fetch after a publish; stories() / summaries() / get(name) are signals.
  • StoryStore is list / load / save / remove. LocalStoryStore is also the JSON boundary: toJson() / exportStory() / importJson() (reviving releaseDate from ISO); the Firestore store crosses the same boundary with Timestamp.
  • stretched-components never imports Firebase; only the app binds { provide: STORY_STORE, useExisting: StoryStoreService } on the admin route (Page Builder).

The serializer

storage/story-serializer.ts bridges the builder's in-memory DynamicFormDefinition and the stored Story:

  • storyFromFormDefinition(definition, metadata) — the builder's story namespace is Story.name. Throws on a blank or whitespace-containing name (it's a Firestore doc id and the $story.name expression namespace) and on any kind: 'service' config in the tree (live instances can't be persisted). kind is stripped from every node.
  • formDefinitionFromStory(story, registry) / configFromStored(...) — re-derive kind from the registry, recursing into data['children']. Unknown component keys throw.

Locale overrides

A non-English story is not a second Story — it's a thin NonEnglishStoryOverride doc (libs/stretched-types/src/story/story-locale.ts) keyed to a story + language: only strings are replaced; the graph (structure, math, generators) is shared. storage/locale-override.ts merges override strings by dotted path (input.data.label, validators.0.message, data.children.0.data.label under the root map). Unresolvable paths are skipped, so locale docs may safely lag the story schema. StoryTeller applies the override before anything renders.

User answers

storage/user-story-entry.ts snapshots a story namespace's input variable values from the context into a UserStoryEntry (for user.stories[key][year]) and seeds them back — derived values are skipped both ways (they recompute; see the paradigm above). date values round-trip as ISO strings; encrypted entries are decrypted before seeding. See Data Model and Auth and Users.