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/*.json → catalog.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.ts — FREE_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.sectionis a template string;${key}tokens (parsed bycomponents/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 insidedata['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]>):
- Locale merge — an optional
NonEnglishStoryOverrideis applied first (mergedStory); everything downstream reads only the merged story. - 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. - Sections — each section string is split into text and
${key}parts. A key whose variable has aserviced-inputcomponent becomes aDynamicInputHostwithbinding: { story: story.name, name: key }; apropscomponent renders viaDynamicConfigHost; unknown keys/serviceentries degrade to visible text rather than crashing. - Root tree —
configFromStored(root, registry)reattacheskind, 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).syncStoryregisters it viaApplicationContextService.declareDerived, a reactivelinkedSignalthat resolves through{ $ref }like any variable, so a KPI card can display it. Fail-soft toundefinedwhen an input is missing or the result is non-finite (noNaNin 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:
| Prop | Behavior when falsy |
|---|---|
showWhen | the gate renders nothing at all |
unlockWhen | children stay unmounted; a locked box shows reason instead |
reason | template string — ${key} tokens show the variable's live value |
story | the 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) | |
|---|---|---|
| Who | every user, at runtime | admins, while authoring |
| What | published stories | drafts |
| Seam | STORY_CATALOG_SOURCE → StoryCatalogService (storage/story-catalog.ts) | STORY_STORE token, StoryStore interface (storage/story-store.ts) |
| Backing | the 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 later | app: StoryStoreService (Firestore stories/{name}, apps/stretched/.../core/services/story-store.service.ts); Storybook: LocalStoryStore |
| Default | empty catalog — nothing crashes unwired | LocalStoryStore (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.StoryStoreislist / load / save / remove.LocalStoryStoreis also the JSON boundary:toJson()/exportStory()/importJson()(revivingreleaseDatefrom ISO); the Firestore store crosses the same boundary withTimestamp.stretched-componentsnever 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 isStory.name. Throws on a blank or whitespace-containing name (it's a Firestore doc id and the$story.nameexpression namespace) and on anykind: 'service'config in the tree (live instances can't be persisted).kindis stripped from every node.formDefinitionFromStory(story, registry)/configFromStored(...)— re-derivekindfrom the registry, recursing intodata['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.