# DMAIC 之路 build — contracts between the parallel workstreams

**Read this before touching a file.** Seven workstreams build the spec
([2026-09-02-road-world-class-elearning-spec.md](/docs/plans/2026-09-02-road-world-class-elearning-spec.md))
in parallel on `dev-wk`. Each owns a fixed set of files; the shapes below are how the pieces fit. A
workstream that needs a change in a file it does not own asks the owner (SendMessage) — it never edits it.

Copy rules: [content-rules.md](/docs/modules_handbook/main/dmaic-road/content-rules.md) (all 12).
Numbers: `resources/js/utils/road/math.js` is the source of truth; `cast.js` is the one copy of every
person's numbers. Quote `ryanNumbers()` / `cast.js`, never type a figure twice.

---

## 1 · File ownership

| Stream | Owns (may create / edit) | Must NOT touch |
|---|---|---|
| **core** (main session) | `utils/road/{math,cast,content}.js` (index), `content/options.js`, `ryanCase.js`, everything PHP, routes, migrations, config, PHPUnit tests, `newProjects.js` roi sieve, docs | — |
| **G1 presentation** | `Components/Road/PointStepper.vue`, `Components/Road/Term.vue`, `Components/Road/VizHost.vue`, `Partials/Beats/{beats.js,PointBeat.vue,CheckBeat.vue,QuestionBeat.vue,CardBeat.vue,DeeperBeat.vue,PlaygroundBeat.vue,BeatRail.vue}`, `Road/Stage.vue` + `Stage.test.js`, `utils/road/terms.js` + `terms.test.js`, `utils/road/roadConsistency.test.js`, `Partials/Beats/*.test.js`, `Partials/roadPartials.test.js`, `utils/road/stages.js` (+ `minutes`) + `stages.test.js` | any station's content file, any card, any viz |
| **G2 起** | `utils/road/content/mindset.js`, `Partials/Mindset/**` (stepper, screens, `Mindset/Viz/*`), new `Components/Road/CharacterCard.vue`, new viz `Partials/Viz/{FreedomLedger,DurianLedger,DurianTree,CardHandoff}.vue` | Stage.vue, other stations |
| **G3 D** | `utils/road/content/define.js`, `Partials/Cards/DefineCard.vue`, new `Partials/Define/**` (the ten-screen stepper if Stage.vue's generic screens mode is not enough — coordinate with G1), viz `Partials/Viz/{TenureClock,ThreeGoals,PassiveTarget,StackTimeline,RecycleFlow,DsrFraction,TwoCeilings}.vue`, `Partials/Viz/DsrGauge.vue` (`lines` prop) | other cards |
| **G4 M + A** | `utils/road/content/{measure,analyze}.js`, `Partials/Cards/{MeasureCard,AnalyzeCard}.vue`, viz `Partials/Viz/{ChoiceSwitches,TenantPoolDots,SameAddressRents,TitleVsNameFlip,ChoiceTree,PriceBands,MistakeBeforeAfter,CashLine,OverhangQueue,MrtLineStrip,SupplyRadiusMap,EvidenceLadder,DemandDriverIcons,TierCards,EmpireMap}.vue`, `Partials/Viz/{LeverageBars,FunnelDiagram,ConstructionTimeline}.vue` (add `state` / `guides`), `Partials/Beats/newVsSubsale.js`, `utils/areaGuide/malaysia.js` (Cochrane, Maluri, Monash paragraph) | Stage.vue, PlaygroundBeat.vue |
| **G5 I + C** | `utils/road/content/{improve,control}.js`, `Partials/Cards/{ImproveCard,ControlCard}.vue` + `ImproveCard.test.js`, `utils/road/projectPrefill.js` (+ `sqft`), viz `Partials/Viz/{ThreePrices,SieveVerdict,RentSources,DeveloperChecks,ThreeOwners,GrrReveal,PurchaseTimeline,BuyingSteps,MarkupBar,QuotaTokens,LadCounter,CashVsDsrBuckets,RecordChain,RpgtStairs}.vue`, `Partials/Viz/{PsfCompare,CashflowWaterfall,DsrTimeline}.vue` (add `state`) | Stage.vue, PlaygroundBeat.vue |
| **G6 T** | `utils/road/{takeoffContent,takeoffMath}.js` + `takeoffMath.test.js`, `Partials/Takeoff/**`, `Partials/Viz/Takeoff/**` | content.js index, cast.js |
| **G7 hub + D′ + tools UI** | `utils/road/content/next.js`, `Road/Next.vue`, `Lms/Partials/Road/RoadOverview.vue` + `.test.js`, `Components/Road/{PathMap,SummaryBar,StageHero}.vue` + `road.test.js`, new `Components/Road/{RoadIntro,RoundTwoDiff}.vue`, `Pages/Main/Portal/WealthPlanning/{Edit.vue,Partials/StepProperties.vue,Partials/StepProjection.vue,Partials/StepYou.vue,Partials/StepGoal.vue}`, `Pages/Main/Portal/AnalyzeProperty/Locked.vue`, `Components/AreaGuide/AreaGuideComingSoon.vue`, new `Pages/Main/Portal/Training/Locked.vue`, `Pages/Main/Site/NewProjects.vue` (roi badge only), `Pages/Manage/Leads/Partials/Tabs/RoadTab.vue` | any road content or viz |

Shared files nobody edits without asking core: `Partials/roadFormat.js`, `Partials/Cards/{CardShell,Field,Chips,Readout,cardForm.js}.vue`, `utils/road/projectLinks.js`.

---

## 2 · Content shapes (what every station file exports)

Every station keeps its existing keys (`question`, `concept`, `answer`, `mistakes`, `ryan`, `playground`,
`cases`, `cardIntro`, `cardRules`, `quiz`, `verify`, `glossary`, `aiPrompt`, `deeper`) — older readers and
the advisor's brief still read them — and ADDS:

```js
export const define = {
  question: '…',
  minutes: 35,                                   // printed on screen 1 and the hub row
  objectives: ['算出银行今天肯借你多少', '…', '…'],  // 「学完你能」× 3, in the member's words
  recap: ['…', '…', '…'],                        // 「这一站你带走的」× 3, printed on the check screen
  keyTerms: ['dsr', 'mof', 'own-line'],          // terms.js keys, shown as chips on the check screen

  // NEW — the screens as POINTS. Two layouts:
  //  (a) nine-beat stations (M · A · I · C): `screens` is a MAP keyed by beat key;
  //      Stage.vue renders PointBeat for a beat that has an entry, the legacy
  //      beat component otherwise.
  screens: {
    concept:  [ /* points */ ],
    answer:   [ /* points */ ],   // C's answer is three screens: use keys answerA / answerB / answerC (beats.js lists them)
    mistakes: [ /* points */ ],
    ryan:     [ /* points */ ],
    cases:    [ /* points */ ],
  },
  //  (b) screen stations (起 · D · T): `screens` is an ARRAY of screens in order;
  //      each screen = { key, label (≤ 6 chars, the rail), title, points, quiz?, image? }.
}
```

A **point**:

```js
{
  key: 'p3',                        // unique within the screen
  title: '所以你能买 10 颗',          // ≤ 14 Chinese characters
  line: '自己 100 + 妈妈 900 → 10 颗 → 赚 **200**。',   // ONE sentence; **bold** allowed for the number
  viz: { kind: 'durian-ledger', state: 'ten', props: {} } | null,  // see §3
  image: '/main/images/road/foundation-leverage.jpg' | null,       // mood band, point 1 only as a rule
  interaction: { kind: 'slider' | 'pick' | 'tap', … } | null,      // optional, rendered by the viz
  terms: ['margin-call'],           // terms.js keys first DEFINED on this point (for the first-use test)
}
```

Rules for points (content-rules.md 5 · 11 · 12): one idea per point; every number bold with its unit; the
reason in the point before the claim; no forward reference by acronym; the example carried to its end.

---

## 3 · The diagram `state` protocol

- A viz component takes `state: String` (required, default `'final'`) and any `props` the point passes,
  and renders **deterministically per state** — no timers except the `useVizReveal` easing; under
  `prefers-reduced-motion` it lands on the state's final frame immediately.
- `kind` is the component's file name in kebab-case: `Partials/Viz/DurianLedger.vue` → `durian-ledger`;
  `Partials/Mindset/Viz/EsbiQuadrant.vue` → `esbi-quadrant`; `Partials/Viz/Takeoff/EngineSimulator.vue` →
  `engine-simulator`. **`VizHost.vue` (G1) resolves kinds with `import.meta.glob` over those three
  folders — nobody registers anything by hand.** Existing viz keep their current props; add `state` and
  keep the old default behaviour when `state === 'final'`.
- Every viz: hand SVG / CSS grid, the palette in `Partials/Viz/palette.js`, `role="img"` + `aria-label`
  that states the facts in words, no fact carried by colour alone, Chinese text in HTML (never SVG
  `<text>`), fits a 360 px wide phone at ≤ 55 vh.
- A viz that a point drives must **read the numbers from math.js / cast.js**, not from literals, unless
  the point is about a hypothetical (the durian). Ryan's numbers come from `ryanNumbers()`.

---

## 4 · Terms and cast

- `utils/road/terms.js` (G1): `export const TERMS = { dsr: { en: 'Debt Service Ratio', zh: '偿债比率', def: '…', definedAt: 'define/D3' }, … }`.
  Every station lists the keys it DEFINES per point (`terms: [...]`) and `terms.test.js` walks the road
  in order (起 screens → D screens → M · A · I · C beats in `beats.js` order → T screens → D′ → hub) and
  fails when a term's first textual occurrence (`en` or `zh` or the key's aliases) precedes the point that
  defines it. Content authors: put the definition sentence on the point where the term first appears.
- `utils/road/cast.js` (core): `RYAN`, `RYAN_MEASURE`, `RYAN_ANALYZE`, `RYAN_IMPROVE`, `RYAN_CONTROL`,
  `RYAN_TAKEOFF`, `RYAN_CARD`, `AQIANG`, `XIAOMING`, `BOOK_CASE`. Import — never re-declare.

---

## 5 · Stage.vue (G1) contract for station authors

- A station whose `screens` is an ARRAY is railed by screens (like today's Mindset / Takeoff): the rail
  shows `screen.label`, `?beat=N` addresses the screen, `?p=M` the point. Its card is the last screen
  (`{ key: 'card', kind: 'card' }`) rendered with the station's Card component.
- A station whose `screens` is a MAP keeps the nine beats; `beats.js` order becomes
  `question · concept · answer(…) · mistakes · ryan · playground · cases · check · card`. `PointBeat`
  renders the map entry for a beat; the legacy beat component renders when there is no entry.
- `QuestionBeat` prints `objectives` + `minutes`; `CheckBeat` prints `recap` + `keyTerms` chips + `Quiz`
  + `GlossaryChips`; `CardBeat` = the card + `HandoffButtons` + `LessonsShelf` + the AI Coach prompt.
- Keyboard: ← → move points within a screen, then beats; never when the target is a field / slider.

---

## 6 · Definition of done (each stream)

1. `npx vitest run <your files>` green, and `npx vitest run resources/js/utils/road resources/js/Pages/Main/Portal/Road resources/js/Components/Road` green for what you touched (do not "fix" another stream's failing test — message its owner).
2. Every screen you wrote passes the first-time reader checklist in `content-rules.md`; every number in
   copy is one `math.js` / `cast.js` produces.
3. `npm run lint` (if configured) clean for your files; no `console.log`.
4. Report: files created / edited, what is still open, and anything you needed from another stream.
5. Do NOT run `npm run build` / `vite build` (the live site) — core builds once at the end with
   `bash scripts/live-build.sh`. Do NOT commit.

## Point shape, 2026-09-03 (the founder's walkthrough)

A point is now `{ key, title (≤14), line, calc?, viz, image?, interaction?, terms? }`:

- **`calc`** — `{ title?, steps: [{ label, calc, result?, note?, tone? }], footnote? }`, rendered by
  `PointStepper` through `Components/Road/CalcSteps.vue`. Rule 17: a calculation is steps, one per
  line, each labelled. A point whose arithmetic has no diagram to live in puts it here; never chain
  it into the sentence.
- **`interaction.label`** is printed as 「试一试：…」 under the line, and the control itself carries a
  visible hint until it is used. A sentence that mentions a control is not a prompt.
- The **`#below` slot** of `PointStepper` holds everything a screen shows under its point (quiz, tool
  cards, CTA, testimonial). It renders ABOVE the single forward button, which is the last thing on
  the screen — there is exactly one forward control per screen.
- `TestimonialCard` is mounted once per station by `CardBeat`, keyed by the stage.
