@tekyzinc/gsd-t 5.16.10 → 5.16.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,103 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.16.12] - 2026-08-29
6
+
7
+ ### Fixed — a retired hook fired on every tool call, on every machine, since M61
8
+
9
+ An audit of installed hooks against what the installer registers found all 16
10
+ expected hooks present and correct — plus one that should not have been there.
11
+
12
+ M61 deleted `scripts/gsd-t-context-meter.js` (native `/context` replaced it) and
13
+ unwired the subsystem from `init()` and `doctor()`. `install()` was missed, so it
14
+ kept calling `configureContextMeterHooks()` and re-adding a `PostToolUse` hook
15
+ matching `*` on every install and every update.
16
+
17
+ **It never errored, which is why it survived eight months.** The command is
18
+ guarded `[ -f … ] && node … || true`, so on every single tool call it spawned a
19
+ bash + `npm root -g` subprocess, found nothing, and exited 0 silently — a real
20
+ cost with no signal.
21
+
22
+ The fix is two parts, because dropping the registration alone fixes nobody: a
23
+ machine that installed the hook once keeps running it out of its own
24
+ `settings.json`, and the installer is the only thing that reaches those machines.
25
+
26
+ - `bin/gsd-t.js`: removed the `configureContextMeterHooks()` call from `install()`
27
+ - `bin/gsd-t.js`: added the marker to `removeRetiredHooks()`, so existing machines
28
+ get it stripped on their next install — the same mechanism that retired the
29
+ M105 worktree guard
30
+ - `bin/gsd-t.js`: exported `removeRetiredHooks` — it was not testable from
31
+ outside at all, which is part of why this went unnoticed
32
+ - `test/m61-context-meter-hook-retired.test.js`: 6 regression tests, mutation-tested
33
+ by reverting each half of the fix
34
+ - `.gsd-t/contracts/graph-metrics-contract.md`: stale line citation for `doMetrics`
35
+ (:5486 → :5488), shifted by the edit above and caught by the M99 contract-line test
36
+
37
+ `configureContextMeterHooks` and `removeContextMeterHook` stay defined for the
38
+ uninstall path. No migration needed — the next `gsd-t update` removes the hook.
39
+
40
+ ## [5.16.11] - 2026-08-27
41
+
42
+ ### Fixed — the voice fix was the wrong fix, and the gate was measuring the wrong thing
43
+
44
+ Both corrections came from running the shipped pipeline against real work, and
45
+ both invalidate advice published hours earlier in 5.16.10.
46
+
47
+ **Pick a text-to-speech service whose voice is a fixed trained speaker.** The
48
+ source run had fought drift with batching — 8 sentences per request so one
49
+ request meant one performance, audio split back apart on silences, splits
50
+ verified, whole takes re-rendered when they measured as drifted. All of that was
51
+ mitigation for a property of the wrong kind of service. A language model's audio
52
+ output treats a voice name as a style hint it re-interprets on every request, so
53
+ the narrator genuinely changes part-way through a video; batching reduces how
54
+ often that happens and cannot stop it, because a batch boundary is still a
55
+ boundary between two readings. Moving to a dedicated speech service, where a
56
+ voice id is a fixed trained speaker, ended it in one change — and took batch
57
+ boundaries, silence splitting, miscut clips and per-model daily quotas with it.
58
+ One sentence per request became both the simple thing and the correct thing. The
59
+ batching guidance is kept, demoted to what to do when you cannot switch.
60
+
61
+ **Gate on mean pitch, not within-video pitch spread.** The published gate failed
62
+ **nine of the twelve videos its own project had already shipped** — every one
63
+ confirmed by ear as a single steady narrator. A ±35 Hz spread threshold is right
64
+ for a language-model TTS, where a wide spread means a different reading, and
65
+ wrong for a fixed speaker, where it measures ordinary sentence intonation: a
66
+ question rising, a list falling, a short line sitting higher. Flattening that
67
+ would make the narration robotic. The speaker-identity signal is the mean, which
68
+ across those twelve videos sat inside a 7 Hz band. So the gate now checks the
69
+ mean against a calibrated band and reports the spread without gating it — ten of
70
+ twelve pass, and the two that still fail do so on volume, which is real.
71
+
72
+ The rule this produces, now in the command: **calibrate a gate against output a
73
+ human has already approved before trusting it.** A gate that fails most of your
74
+ known-good work is measuring the wrong thing, and believing it costs you
75
+ re-rendering audio that was already correct.
76
+
77
+ **Narration lines must be full sentences of 8 words or more.** Integrated
78
+ loudness needs enough audio to measure against; a two-word clip ("Create
79
+ Curriculum.") lands off target, blows the video's volume spread and trips the
80
+ miscut check. This cost two full render cycles before it was diagnosed. When a
81
+ step needs an action the narration does not describe, run it untimed rather than
82
+ inventing a stub line.
83
+
84
+ **Templates:** `choose()` opens a dropdown, holds it open long enough to read,
85
+ and picks — the choice is the most informative moment in a create-flow, and a
86
+ highlighted select shows the viewer nothing. `enter()` types a value visibly
87
+ rather than filling it instantly. `act()` performs either outside a narrated
88
+ beat, because both only build an action and a bare `await` on one silently does
89
+ nothing. New `cast.mjs` carries every name said aloud in one place.
90
+
91
+ **Narration guidance rewritten around two woven stories** — the operator's, told
92
+ in their own voice and shown on screen, and the customer's, which is why every
93
+ value typed is that value. The test: a sentence that survives with the names
94
+ removed is explaining the software, not telling the story.
95
+
96
+ - `commands/gsd-t-demo-videos.md`: Stage 1 rewritten; cast step gains the
97
+ two-story weave, the products/pricing rule and the dropdown mandate
98
+ - `templates/demo-videos/e2e/cast.mjs`: new
99
+ - `templates/demo-videos/e2e/runtime.ts`: `enter()`, `choose()`, `act()`
100
+ - `templates/demo-videos/e2e/example.{lines.mjs,spec.ts}`: rewritten as a story
101
+
5
102
  ## [5.16.10] - 2026-08-27
6
103
 
7
104
  ### Added — `/gsd-t-demo-videos`, narrated walkthrough videos of a running app
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.16.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.16.12** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
package/bin/gsd-t.js CHANGED
@@ -1205,11 +1205,16 @@ function removeInterceptHooks(settingsPath) {
1205
1205
  // session signal, but subagents write those too, so one session with agents
1206
1206
  // looked like several colliding sessions and the guard blocked its own user.
1207
1207
  //
1208
+ // gsd-t-context-meter — M61, script deleted; native /context replaced it.
1209
+ // init() stopped provisioning it then, but install() kept re-registering it,
1210
+ // so every machine still runs it on EVERY tool call: a bash + `npm root -g`
1211
+ // subprocess spawned to look for a file that no longer ships.
1212
+ //
1208
1213
  // Throws on any failure. A retired hook that silently survives keeps blocking
1209
1214
  // edits forever, so "could not remove it" must stop the install loudly rather
1210
1215
  // than report success.
1211
1216
  function removeRetiredHooks(settingsPath) {
1212
- const RETIRED_HOOK_MARKERS = ["gsd-t-worktree-guard"];
1217
+ const RETIRED_HOOK_MARKERS = ["gsd-t-worktree-guard", CONTEXT_METER_HOOK_MARKER];
1213
1218
  const targetPath = settingsPath || SETTINGS_JSON;
1214
1219
  if (!fs.existsSync(targetPath)) return { removed: 0 };
1215
1220
 
@@ -2285,13 +2290,10 @@ async function doInstall(opts = {}) {
2285
2290
  heading("Global Bin Tools (~/.claude/bin/)");
2286
2291
  installGlobalBinTools();
2287
2292
 
2288
- heading("Context Meter (PostToolUse)");
2289
- const cmHook = configureContextMeterHooks(SETTINGS_JSON);
2290
- if (cmHook.installed) {
2291
- if (cmHook.action === "added") success("Context meter PostToolUse hook added");
2292
- else if (cmHook.action === "updated") success("Context meter hook command refreshed");
2293
- else info("Context meter hook already configured");
2294
- }
2293
+ // M61: Context Meter retired (scripts/gsd-t-context-meter.js deleted; native
2294
+ // /context replaces it). The configureContextMeterHooks() call that stood here
2295
+ // re-registered a PostToolUse hook pointing at that deleted script on every
2296
+ // install. removeRetiredHooks() below now strips it instead.
2295
2297
 
2296
2298
  heading("Graph-Intercept (PostToolUse on Grep — M97)");
2297
2299
  const giHook = configureGraphInterceptHook(SETTINGS_JSON);
@@ -2318,7 +2320,7 @@ async function doInstall(opts = {}) {
2318
2320
 
2319
2321
  const retired = removeRetiredHooks(SETTINGS_JSON);
2320
2322
  if (retired.removed > 0) {
2321
- success(`Removed ${retired.removed} retired hook(s) from settings.json (worktree-collision guard — M105)`);
2323
+ success(`Removed ${retired.removed} retired hook(s) from settings.json (worktree-collision guard — M105; context meter — M61)`);
2322
2324
  }
2323
2325
 
2324
2326
  // M105 worktree-collision guard RETIRED (2026-08-08). It detected sessions from
@@ -5610,6 +5612,7 @@ module.exports = {
5610
5612
  installContextMeter,
5611
5613
  configureContextMeterHooks,
5612
5614
  removeContextMeterHook,
5615
+ removeRetiredHooks,
5613
5616
  // M97/M98: intercept hook installers
5614
5617
  configureGraphInterceptHook,
5615
5618
  configureReadInterceptHook,
@@ -39,8 +39,8 @@ transition, and a slideshow does not read as software being used.
39
39
  | A **running app with real data** — deployed/preview URL, not a local build with an empty database | An empty tenant on video is indistinguishable from a feature that was never built | Yes — ask for the URL, a login, and the name of a tenant that actually has data |
40
40
  | **Playwright** installed | The recorder | Yes — `gsd-t setup-playwright` |
41
41
  | **ffmpeg** | Every audio operation | Yes |
42
- | **auto-editor** (Python venv, bundled into the project, not PATH) | Silence removal, Stage 5 | Yes |
43
- | A **TTS key** with quota | The narrator | Yes — see § Quota |
42
+ | **auto-editor** (Python venv, bundled into the project, not PATH) | Silence removal, Stage 4 | Yes |
43
+ | A **text-to-speech service with FIXED voices** (e.g. Google Cloud TTS) | The narrator. A language-model TTS re-reads a style hint per request and the voice drifts | Yes — see Stage 1 |
44
44
 
45
45
  **Ask for the tenant by name.** In the source run, two videos were filmed
46
46
  against the wrong location before the right ID was pinned down; the numbering
@@ -118,12 +118,121 @@ Two silent-failure traps seen in practice, worth checking for in any form:
118
118
  Also: several elements can carry `role=dialog` (a sidebar, an assistant panel).
119
119
  Match a dialog by its title, never `.first()`.
120
120
 
121
- State plainly in the handoff whether seeded data survives (a shared demo site
122
- often resets nightly).
121
+ **Every cast member must have a script that recreates it.** A shared demo site
122
+ usually resets (nightly is common), so anything the walkthrough names aloud is
123
+ gone by morning — and a walkthrough that references a course which no longer
124
+ exists fails at its first selector. Treat the reset as normal and make the data
125
+ reproducible: one seeder per cast member, re-runnable, and idempotent where the
126
+ app allows it (check whether the record is already there and say so, rather than
127
+ creating a duplicate).
128
+
129
+ `--seed` re-runs the whole cast, so the day's first recording starts from a known
130
+ state. State plainly in the handoff which parts of the cast are seeded ahead and
131
+ which are created live on camera.
123
132
 
124
133
  ---
125
134
 
126
- ## Step 4 — WRITE the narration as beats
135
+ ## Step 4 — CAST the demo (do this BEFORE writing a word)
136
+
137
+ **A demo that describes what a form is for, while the form sits empty, teaches
138
+ nothing.** The viewer learns that a button exists — not what the software does.
139
+ That is the clinical failure, and it is what this step removes.
140
+
141
+ **Two stories, woven.** The operator's story is what happens on screen, told in
142
+ their own voice — *"I'm setting fifty-five hours."* The customer's story is why
143
+ every value they type is that value. Neither works alone: the customer alone is a
144
+ bio, the operator alone is a person explaining a form.
145
+
146
+ | The reason | The decision | On screen |
147
+ |---|---|---|
148
+ | Maya works night shifts | Tyler picks Part 61, not Part 141 | dropdown → **Part 61** |
149
+ | Maya has never flown | Tyler sets 55 hours, not the FAA's 40 | types **55** |
150
+ | Maya can only fly mornings | Tyler assigns James, who flies mornings | dropdown → **James Rivera** |
151
+
152
+ **The reason comes BEFORE the value, in the same breath** — *"She's on nights,
153
+ so — Part 61."* Value-then-justification is the teacher voice creeping back.
154
+
155
+ **The tell that you have slipped back into explaining:** a "because" clause
156
+ pointing at the software. *"so it's required rather than optional"*, *"which is
157
+ what the invoice uses later"*, *"the schedule refuses it otherwise"*. Every
158
+ reason must point at the customer, never at the mechanism. Read the finished
159
+ narration aloud: **a sentence that would survive with the names removed is
160
+ explaining the software, and it is wrong.**
161
+
162
+ Pick real, named specifics and write them down as a cast list before any
163
+ narration is drafted. Not "a course" — a course with a name someone could say out
164
+ loud. Not "a student" — a person with a name.
165
+
166
+ Put the cast in ONE constants block that both the seeder and the spec import, so
167
+ a name can be changed in a single edit and can never drift between the narration
168
+ and the screen:
169
+
170
+ ```js
171
+ // e2e/walkthrough/cast.mjs — the demo's cast. One edit changes it everywhere.
172
+ export const CAST = {
173
+ course: 'Private Pilot Certificate — Part 61',
174
+ student: { first: 'Maya', last: 'Ellison', email: 'maya.ellison@example.com' },
175
+ // …aircraft, instructor, dates — everything the walkthrough names aloud
176
+ };
177
+ ```
178
+
179
+ **Three rules, and the third is the one a spec silently loses:**
180
+
181
+ 1. **Every named thing is real and specific.** A syllabus with actual stage
182
+ names, a certificate someone actually earns, a rate someone actually pays.
183
+ Generic placeholders (`Test Course 1`, `Student A`) read as fake and make the
184
+ whole demo read as fake with them.
185
+
186
+ 2. **The data is ENTERED on camera, not described — and EVERY DROPDOWN IS
187
+ OPENED AND PICKED.** The walkthrough types the values and saves. A sentence
188
+ explaining what a field is for, over an empty field, is the defect.
189
+
190
+ A dropdown that is merely highlighted shows nothing: the viewer cannot see
191
+ what the alternatives were, or that a choice happened at all. **The choice is
192
+ the most informative moment in a create-flow** — it is where the customer's
193
+ situation becomes the operator's decision. Use `choose()`, never
194
+ `highlight()`, on a select.
195
+
196
+ If the flow creates something, the demo creates it — that also proves the
197
+ create-flow works, which describing it never does.
198
+
199
+ 3. **The names CARRY FORWARD.** Once the course is created, every later sentence
200
+ says that course BY NAME. Once the student is enrolled, they are referred to
201
+ by name for the rest of the video — "Maya's next lesson", not "the student's
202
+ next lesson". This is what makes the walkthrough one story instead of a tour
203
+ of screens. It is easy to lose because each step is written independently, so
204
+ check it as a pass over the finished narration: **a sentence that says "the
205
+ student" or "the course" after the cast has been introduced is a bug.**
206
+
207
+ **Give any value with a symbol or abbreviation a spoken twin.** The narrator
208
+ reads text literally, so `$185/hr` comes out as "dollar one eight five slash h
209
+ r" and `9:00 AM` as "nine colon zero zero A M". Keep the typed value for the
210
+ form field and a said-aloud version for the sentence, both in the cast block.
211
+
212
+ **Price it with the real billable parts.** A course, a plan or a subscription is
213
+ the SUM of the things a charge attaches to, so build those things on camera and
214
+ attach them — not one summary price. In the flight-school example that is four
215
+ products: the airplane per hour, the instructor per hour, ground instruction per
216
+ hour, and the materials kit once. The payoff line is the one that makes the whole
217
+ section land: *"an hour of dual bills Maya two-sixty — a hundred and eighty-five
218
+ for the airplane, seventy-five for James"* is arithmetic the viewer just watched
219
+ being set up.
220
+
221
+ **Order the walkthrough as the real-life sequence**, so each screen is visited
222
+ because the previous one made it necessary: build the course → enrol the named
223
+ student → schedule their first lesson → fly it → bill it. That ordering is what
224
+ makes the dependency context land ("before you can schedule a student for a
225
+ course, a program must exist and be linked to a course") instead of being
226
+ asserted.
227
+
228
+ **When creation hits a guardrail**, that is information, not a blocker — the app
229
+ refusing an incomplete enrolment is worth showing. But do not fight it on camera:
230
+ fall back to an existing named record, and say in the handoff which parts of the
231
+ cast are created live and which are pre-seeded.
232
+
233
+ ---
234
+
235
+ ## Step 5 — WRITE the narration as beats
127
236
 
128
237
  Two files per walkthrough, and the separation is load-bearing:
129
238
 
@@ -146,12 +255,19 @@ Narration rules, each from a user correction:
146
255
  reads from it.
147
256
  - **Count what is on screen before writing about it.** "Eight-step wizard" shipped
148
257
  in a video where the UI says *Step 1 of 9*.
258
+ - **Say the cast's names, every time.** After Step 4's cast is introduced, "the
259
+ student" and "the course" are bugs — it is *Maya Ellison* and the *Private
260
+ Pilot Certificate — Part 61*. Read the finished narration once looking only
261
+ for this.
262
+ - **Narrate the value being typed, not the field's purpose.** "Her first lesson
263
+ is Tuesday at nine, with James in the Cessna 172" — not "you would select a
264
+ date, an instructor and an aircraft here".
149
265
  - **Naming a whole strip highlights the strip; making a point about one control
150
266
  highlights that control; navigating by it moves the mouse and clicks it.**
151
267
 
152
268
  ---
153
269
 
154
- ## Step 5 — the five-stage build
270
+ ## Step 6 — the five-stage build
155
271
 
156
272
  Run in this order, every time. Nothing here is optional.
157
273
 
@@ -171,68 +287,72 @@ recordings to find. Preflight walks the same screens without recording and
171
287
  reports **every** missing target in one pass. It never asserts; it prints a
172
288
  report. The real gate is still the recording itself.
173
289
 
174
- ### Stage 1 — Voice (the hardest-won stage)
175
-
176
- **Cause of tone drift, confirmed: one API request per sentence.** The model
177
- re-decides its delivery on every request, so the narrator audibly changed within
178
- a single video. Describing the speaker in the prompt does not fix it it is
179
- being asked to act, fresh, dozens of times.
180
-
181
- Two deterministic fixes, neither of which rests on listening and deciding it
182
- sounds fine:
183
-
184
- **Volume forced.** Every clip goes through a two-pass ffmpeg `loudnorm` to the
185
- same target (EBU R128, −18 LUFS). Measured effect: within-video drift went from
186
- as much as **7.5 dB to ~0.5 dB**, and every video sits at exactly −18.0 so they
187
- match each other too. A normalise-only script fixes existing clips with no API
188
- calls.
189
-
190
- **Tone batched, then verified.** Sentences go **8 per request**, numbered, with
191
- an instruction to leave two seconds of silence between them; the returned audio is
192
- cut back apart on those silences. One request means one performance.
193
-
194
- > **BATCH SIZE IS 8 AND THAT WAS MEASURED.** Bigger is not better: an 18-line
195
- > script as a single take gave a pitch spread of **46 Hz**; the same script in
196
- > 8-line batches gave **16 Hz**. Line-by-line pitch tracing showed why the
197
- > narrator holds ~100–115 Hz for a dozen lines then slips (140 Hz at line 15),
198
- > losing the persona the further it gets from the instruction. Re-measure before
199
- > changing it.
200
-
201
- **The split is verified, never assumed.** If a batch does not come back with the
202
- expected number of gaps, retry it; if it still disagrees, render those lines one
203
- at a time. Each piece must also be about as long as its sentence takes to say
204
- (~2.8 words/sec, accepted band 0.6×–1.7×). A wrong split puts half a sentence on
205
- the wrong stepworse than the drift it was meant to cure.
206
-
207
- **The gate.** Measure three numbers per clip and report the **spread** across the
208
- video: loudness (LUFS), pitch (median fundamental, Hz), speaking rate (energy
209
- peaks/sec), plus **miscut** clips. Thresholds: volume 1.5 dB, pitch 35 Hz, rate
210
- 5/s, zero miscuts.
211
-
212
- **Ensure, don't check.** Render measure **throw the take away and re-render
213
- if it drifts** (3 attempts, then halt rather than ship). This is not theoretical:
214
- one video's first take came back at 46 Hz and was discarded for a 15 Hz one;
215
- another needed all three attempts (44 → 41 → 31 Hz).
216
-
217
- > **Do not trust a single A/B run.** A "persona anchor" prompt looked like a large
218
- > win (7 Hz vs plain) and reversed on the next run (21 Hz vs 9 Hz). It was
219
- > run-to-run variance. Two renders of the same prompt genuinely differ — which is
220
- > exactly why the gate exists instead of a one-time tuning pass.
221
-
222
- **Keep the voice identical across the whole set**, not just within a video. Voice
223
- name, speed, model, target loudness and persona text all belong in the cache key:
224
- change any one and the video re-renders rather than mixing two deliveries.
225
-
226
- **Retry every one of these** each returned something other than usable audio and
227
- was fatal until handled:
228
-
229
- | Symptom | Handling |
230
- |---|---|
231
- | transient `400 INVALID_ARGUMENT` | retry — the identical request succeeds moments later |
232
- | `200` carrying no audio | retry the model answered without speech |
233
- | a call with no deadline | time it out; a hung render looks exactly like a working one |
234
- | a crashed attempt | the ensure loop catches it and retries, rather than losing the video |
235
- | `429` per-day quota | switch model or key — see § Quota |
290
+ ### Stage 1 — Voice
291
+
292
+ **Pick a text-to-speech service whose voice is a FIXED TRAINED SPEAKER, not a
293
+ language model reading a style hint.** This one choice decides whether the
294
+ narrator can drift at all, and everything else in this stage follows from it.
295
+
296
+ The source run learned it the expensive way. It started on a language model's
297
+ audio output (Gemini `generateContent`), where a voice name is a *style hint the
298
+ model re-interprets on every request* — so the narrator audibly changed
299
+ part-way through a video. Enormous effort went into mitigation: batching 8
300
+ sentences per request so one request meant one performance, splitting the
301
+ returned audio back apart on silences, verifying every split, and re-rendering
302
+ whole takes that measured as drifted. It reduced how OFTEN the voice changed and
303
+ could never stop it, because **a batch boundary is still a boundary between two
304
+ different readings**.
305
+
306
+ Moving to a dedicated speech service (Google Cloud Text-to-Speech) ended it in
307
+ one change. A voice id there is a fixed trained speaker: the same id returns the
308
+ same speaker every time, forever. That removed batch boundaries, silence
309
+ splitting, miscut clips, per-model daily quotas, and drift — all at once — and
310
+ made **one sentence per request** both the simple thing and the correct thing.
311
+
312
+ > **If you are on a language-model TTS and cannot switch**, the mitigation is
313
+ > batching: 8 sentences per request (measured an 18-line single take spread
314
+ > 46 Hz of pitch, 8-line batches 16 Hz), a verified split that retries rather
315
+ > than guessing where a sentence ended, and a re-render loop. Treat it as a
316
+ > workaround, not a design.
317
+
318
+ **Volume is forced, on every service.** Two-pass ffmpeg `loudnorm` to a fixed
319
+ target (EBU R128, −18 LUFS). Measured on the shipped set: ±0.3–0.7 dB within a
320
+ video, every video landing on the same target so they match each other too. This
321
+ is worth doing even with a fixed speaker it is the one number a service will
322
+ not hold steady for you.
323
+
324
+ **Every narration line must be a full sentence 8 words or more.** Integrated
325
+ loudness needs enough audio to measure against; a two- or three-word clip
326
+ ("Create Curriculum.") lands off target and blows the video's volume spread,
327
+ and also trips miscut and speaking-rate checks. If a step needs an action the
328
+ narration does not describe, run it untimed rather than inventing a stub line.
329
+
330
+ ### The gate measure IDENTITY, not expressiveness
331
+
332
+ Measure per clip and report across the video: loudness (LUFS), pitch (median
333
+ fundamental, Hz), speaking rate (energy peaks/sec), and **miscut clips** a
334
+ clip whose length is far from what its sentence should take to say.
335
+
336
+ **Gate on the MEAN pitch, not the within-video spread.** This is the correction
337
+ that matters, and it was found by running the gate against twelve videos a human
338
+ had already confirmed sounded perfect: **a ±35 Hz spread threshold failed nine
339
+ of them.** Within-video pitch spread is ordinary sentence intonation a
340
+ question rising, a list falling, a short line sitting higher and flattening it
341
+ would make the narration robotic. The speaker-identity signal is the mean: across
342
+ those same twelve videos it sat in a 7 Hz band (102–109 Hz), which is what a
343
+ fixed speaker looks like.
344
+
345
+ Working thresholds: volume ±1.5 dB, mean pitch inside a band calibrated from
346
+ known-good output, rate ±5.5/s, zero miscuts.
347
+
348
+ > **Calibrate a gate against output a human has approved, before trusting it.**
349
+ > A gate that fails most of your known-good work is measuring the wrong thing,
350
+ > and the cost of believing it is re-rendering audio that was already correct.
351
+
352
+ **Ensure, don't just check.** Render → measure → re-render if it fails (3
353
+ attempts, then halt rather than ship). Even with a fixed speaker this catches a
354
+ bad take: `build-a-course` failed its first attempt on volume and passed the
355
+ second.
236
356
 
237
357
  ### Stage 2 — Record
238
358
 
@@ -302,7 +422,7 @@ The user's verdict on this stage was "It's perfect. Run this after every video."
302
422
 
303
423
  ---
304
424
 
305
- ## Step 6 — VERIFY before showing the user
425
+ ## Step 7 — VERIFY before showing the user
306
426
 
307
427
  The user should never be the one who finds these. Check, per video:
308
428
 
@@ -319,12 +439,28 @@ The user should never be the one who finds these. Check, per video:
319
439
  `highlight` → `goto` adjacency. (Audited at 22 instances across 6 videos in
320
440
  the source run.)
321
441
  6. **Counts in the narration match the UI** — tabs, wizard steps, row counts.
442
+ 7. **The cast is named throughout** — grep the finished narration for "the
443
+ student", "the course", "a user", "the aircraft". After the cast is
444
+ introduced, each of those is a line that should say a name instead.
445
+ 8. **Data was entered, not described** — any step whose sentence explains what a
446
+ field is for should be typing into that field. A form that stays empty while
447
+ the narration explains it is the clinical failure this exists to prevent.
448
+ 9. **Every dropdown was opened and picked** — grep the spec for `highlight(`
449
+ on a select; each one should be `choose()`. The step log records `choose`
450
+ as its own action, so count them against the number of selects in the flow.
322
451
 
323
452
  Then show the user each video as it finishes, not in a batch at the end.
324
453
 
325
454
  ---
326
455
 
327
- ## Quota — it is per PROJECT and per MODEL
456
+ ## Quota and auth only if you are on a language-model TTS
457
+
458
+ A dedicated speech service typically bills per character with no per-model daily
459
+ cap, and authenticates with a cloud login rather than an API key (Google Cloud
460
+ TTS is OAuth-only — it refuses an API key, and needs a quota project). If that is
461
+ what you are on, this section does not apply.
462
+
463
+ On a language-model TTS the quota rules bite hard:
328
464
 
329
465
  - A free tier can be as low as **10 requests per day per model**, and the error
330
466
  names it (`…PerDayPerProjectPerModel-FreeTier`). It dies almost immediately.
@@ -351,12 +487,14 @@ docs/demo-videos/HANDOFF.md hard-won facts, bugs found, what is ope
351
487
  docs/demo-videos/walkthrough-<name>.mp4 output (gitignore it)
352
488
  e2e/walkthrough/<name>.lines.mjs narration, one sentence per entry
353
489
  e2e/walkthrough/<name>.spec.ts what happens on screen per sentence
354
- e2e/walkthrough/runtime.ts step(), highlight(), click(), goTo()
490
+ e2e/walkthrough/runtime.ts step(), highlight(), click(), enter(), choose(), act(), goTo()
491
+ e2e/walkthrough/cast.mjs the demo's cast — every name said aloud
355
492
  e2e/walkthrough/signin.ts shared sign-in + the tenant constant
356
493
  e2e/walkthrough/manifest.ts loads narration; skips if audio is missing
357
494
  e2e/walkthrough/preflight.spec.ts checks every target without recording
358
- scripts/walkthrough-voice.mjs batched TTS, loudness-normalised
359
- scripts/walkthrough-voice-check.mjs measures spread + miscuts; exit 4 on drift
495
+ scripts/walkthrough-voice-gcloud.mjs the narrator — fixed-voice TTS, one sentence/request
496
+ scripts/walkthrough-voice.mjs SUPERSEDED batched language-model TTS
497
+ scripts/walkthrough-voice-check.mjs volume spread + MEAN-pitch identity + miscuts; exit 4
360
498
  scripts/walkthrough-voice-ensure.mjs render → measure → re-render → halt
361
499
  scripts/walkthrough-normalise.mjs force existing clips to one loudness
362
500
  scripts/walkthrough-mux.mjs lay audio on the recording
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.16.10",
3
+ "version": "5.16.12",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -71,11 +71,12 @@ videos in the source run.
71
71
 
72
72
  | File | Role |
73
73
  |---|---|
74
- | `e2e/runtime.ts` | `step()`, `highlight()`, `click()`, `goTo()`; narration is the clock; throws when a sentence has no visible target |
74
+ | `e2e/runtime.ts` | `step()`, `highlight()`, `click()`, `enter()`, `choose()`, `act()`, `goTo()`; narration is the clock; throws when a sentence has no visible target |
75
75
  | `e2e/manifest.ts` | loads narration; a spec **skips** when its audio is missing rather than failing the run |
76
76
  | `e2e/signin.ts` | shared sign-in + the tenant constant — **ask for this, don't derive it** |
77
77
  | `e2e/preflight.spec.ts` | walks every target without recording, reports all misses in one pass |
78
- | `e2e/example.lines.mjs` | narration shape, with the writing rules |
78
+ | `e2e/cast.mjs` | **the demo's cast** — Tyler (the voice), Maya (the reason), the syllabus and the priced products; spoken twins for anything with a symbol |
79
+ | `e2e/example.lines.mjs` | narration shape, with the writing rules — follows one named student through one named course |
79
80
  | `e2e/example.spec.ts` | spec shape, with the overlay/settle rules |
80
81
  | `scripts/walkthrough-voice.mjs` | batched TTS (8/request), verified split, two-pass loudnorm |
81
82
  | `scripts/walkthrough-voice-check.mjs` | measures LUFS/pitch/rate spread + miscuts; **exit 4** on drift |
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The demo's CAST — every name and value the walkthrough says out loud or types.
3
+ *
4
+ * TWO STORIES, WOVEN
5
+ * ------------------
6
+ * Tyler owns the flight school; his is the story on screen, in his own voice.
7
+ * Maya is why every value he types is THAT value. Neither works alone — Maya
8
+ * by herself is a bio, Tyler by himself is a man explaining a form.
9
+ *
10
+ * Maya works night shifts -> Tyler picks Part 61, not Part 141
11
+ * Maya has never flown -> Tyler sets 55 hours, not the FAA's 40
12
+ * Maya can only fly mornings-> Tyler assigns James, who flies mornings
13
+ *
14
+ * The reason comes BEFORE the value, in the same breath: "She's on nights, so —
15
+ * Part 61." Value-then-justification is the teacher voice creeping back.
16
+ *
17
+ * THREE RULES:
18
+ * 1. Real and specific. "Private Pilot Certificate — Part 61", not
19
+ * "Test Course 1". A generic placeholder makes the whole demo read as fake.
20
+ * 2. ENTERED on camera, not described — including EVERY DROPDOWN. A select
21
+ * that is merely highlighted shows nothing: the viewer cannot see what the
22
+ * alternatives were or that a choice was made. Open it, pick the value.
23
+ * 3. The names CARRY FORWARD. After the cast is introduced, every later
24
+ * sentence uses the name — "Maya's first lesson", never "the student's".
25
+ *
26
+ * The example is a flight school; replace it with your own domain. The SHAPE is
27
+ * what matters: a person with a reason, an operator with a decision, and the
28
+ * exact values that get typed and picked.
29
+ */
30
+
31
+ /** The narrator. He speaks in the first person — his school, his decisions. */
32
+ export const TYLER = { name: 'Tyler', role: 'flight school owner' };
33
+
34
+ /** The reason every value below is the value it is. */
35
+ export const MAYA = {
36
+ first: 'Maya',
37
+ last: 'Ellison',
38
+ short: 'Maya',
39
+ email: 'maya.ellison@example.com',
40
+ phone: '(480) 555-0142',
41
+ /** The facts Tyler's choices answer to. Each one drives a field. */
42
+ age: 24,
43
+ ageSpoken: 'twenty-four',
44
+ job: 'works nights at the hospital',
45
+ experience: 'has never flown anything',
46
+ goal: 'her private pilot license by spring',
47
+ availability: 'Weekday mornings',
48
+ };
49
+
50
+ export const STUDENT_NAME = `${MAYA.first} ${MAYA.last}`;
51
+
52
+ /** The course Tyler builds for her. */
53
+ export const COURSE = {
54
+ name: 'Private Pilot Certificate — Part 61',
55
+ /** Dropdown selections — each is OPENED and PICKED on camera. */
56
+ certificate: 'Private Pilot',
57
+ regulation: 'Part 61',
58
+ category: 'Airplane, Single-Engine Land',
59
+ /** Typed. 55 not 40, because nobody finishes in the FAA minimum. */
60
+ hours: '55',
61
+ hoursSpoken: 'fifty-five',
62
+ };
63
+
64
+ /** The syllabus, in the order Maya flies it. Each stage is typed + typed-picked. */
65
+ export const STAGES = [
66
+ { name: 'Ground School', type: 'Ground', hours: '15', spoken: 'fifteen' },
67
+ { name: 'Pre-Solo Flight Training', type: 'Dual', hours: '20', spoken: 'twenty' },
68
+ { name: 'Solo Flight', type: 'Solo', hours: '10', spoken: 'ten', endorsement: true },
69
+ { name: 'Cross-Country', type: 'Dual and Solo', hours: '5', spoken: 'five' },
70
+ { name: 'Checkride Preparation', type: 'Dual', hours: '5', spoken: 'five' },
71
+ ];
72
+
73
+ /**
74
+ * PRODUCTS — everything a charge can be applied to.
75
+ *
76
+ * The sum of a course's products is what an invoice is built from, so these are
77
+ * created first and then attached to the course. This is the spine of the
78
+ * story: build the chargeable things, attach them, and the invoice falls out.
79
+ */
80
+ export const PRODUCTS = [
81
+ { name: 'Cessna 172 — N172SA', type: 'Aircraft Rental', price: '185', unit: 'per hour', wet: true },
82
+ { name: 'Flight Instruction — CFI', type: 'Instructor Time', price: '75', unit: 'per hour' },
83
+ { name: 'Ground Instruction', type: 'Instructor Time', price: '65', unit: 'per hour' },
84
+ { name: 'Private Pilot Kit', type: 'Materials', price: '289', unit: 'One-time' },
85
+ ];
86
+
87
+ /** Everything else Tyler names aloud. */
88
+ export const INSTRUCTOR = { name: 'James Rivera', short: 'James', why: 'flies mornings, good with first-timers' };
89
+ export const AIRCRAFT = { tail: 'N172SA', tailSpoken: 'November one seven two Sierra Alpha', type: 'Cessna 172' };
90
+ export const FIRST_LESSON = { day: 'Tuesday', time: '9:00 AM', timeSpoken: 'nine in the morning' };
91
+
92
+ /**
93
+ * SPOKEN forms. The narrator reads text literally, so "$185/hr" comes out as
94
+ * "dollar one eight five slash h r" and "9:00 AM" as "nine colon zero zero A M".
95
+ * The typed value goes in the form; these go in the sentence.
96
+ */
97
+ export const SPOKEN = {
98
+ aircraftRate: 'a hundred and eighty-five',
99
+ cfiRate: 'seventy-five',
100
+ groundRate: 'sixty-five',
101
+ kitPrice: 'two hundred and eighty-nine',
102
+ dualHourTotal: 'two-sixty',
103
+ courseTotal: 'about thirteen thousand, four hundred',
104
+ };
@@ -1,28 +1,79 @@
1
1
  /**
2
2
  * TEMPLATE — the narration for one walkthrough. Nothing but sentences.
3
3
  *
4
- * A BEAT IS ONE IDEA BEING EXPLAINED, NOT ONE SCREEN. A beat may dwell on three
5
- * things within a screen, or carry across a navigation. Building around screens
6
- * is what produced fixed-length steps and the drift that followed.
4
+ * THIS IS A STORY, NOT A TOUR. Tyler owns the school and speaks in his own
5
+ * voice; Maya is why every value he types is that value. Read it aloud: if a
6
+ * sentence would survive with the names removed, it is explaining the software
7
+ * instead of telling the story, and it is wrong.
8
+ *
9
+ * The failure this replaces: "Her contact details are what the school will use
10
+ * to reach her, so they're required rather than optional" — a sentence about a
11
+ * form. What it should say: "Maya's asleep by eight most nights, so mornings
12
+ * are all she has" — a fact about Maya that lands in a field.
13
+ *
14
+ * THE TELL: a "because" clause pointing at the software. "so it's required",
15
+ * "which is what the invoice uses later", "the schedule refuses it otherwise".
16
+ * Every reason must point at Maya, never at the mechanism.
7
17
  *
8
18
  * Rules, each from a user correction in the source run:
9
- * - Never name something the viewer cannot see. If the sentence names a
10
- * thing, the matching step must point at that thing.
11
- * - Don't invent jargon. Not "groups" "the left sidebar's top-level menus,
12
- * which expand to show…".
19
+ * - EVERY DROPDOWN IS OPENED AND PICKED, and the sentence says the chosen
20
+ * value. A highlighted select shows nothing the viewer cannot see what
21
+ * the alternatives were, or that a choice happened at all.
22
+ * - The reason comes BEFORE the value, in the same breath. "She's on nights,
23
+ * so — Part 61." Not the value followed by a justification.
24
+ * - Narrate the VALUE, not the field's purpose. "Fifty-five hours, because
25
+ * nobody finishes in forty" — not "you would enter the hours here".
26
+ * - Use the SPOKEN form of any value with a symbol or abbreviation. The
27
+ * narrator reads literally: "$185/hr" becomes "dollar one eight five slash
28
+ * h r". cast.mjs carries a said-aloud twin for those.
29
+ * - Don't invent jargon. Not "groups" — "the left sidebar's top-level menus".
13
30
  * - Explain, don't sell. No "exciting", no "powerful", no enthusiasm.
14
- * - Give the dependency context: why this screen exists, and what downstream
15
- * reads from it.
16
31
  * - COUNT WHAT IS ON SCREEN before writing about it. "Eight-step wizard"
17
32
  * shipped in a video where the UI said "Step 1 of 9".
18
33
  *
19
34
  * The order here is the order in the spec. Index N of LINES is L[N] there.
20
35
  */
36
+ import {
37
+ AIRCRAFT, COURSE, FIRST_LESSON, INSTRUCTOR, MAYA, PRODUCTS, SPOKEN, STAGES, STUDENT_NAME,
38
+ } from './cast.mjs';
39
+
40
+ const [PLANE, CFI, GROUND, KIT] = PRODUCTS;
41
+
21
42
  export const LINES = [
22
- "Before anyone can book a flight, the aircraft has to exist here, at the location it lives at.",
23
- "The four cards across the top are the fleet's condition right now: how many are available, how many are grounded, how many open squawks there are, and how much maintenance is coming due.",
24
- "Below that is the fleet itself. Seven aircraft at this location, each with its hourly rate.",
25
- "That rate is what turns a flight into money later, so it belongs to the aircraft, not to the booking.",
26
- "Opening an aircraft gives you its full record, and the record has seven tabs — each one a different kind of history for the same airframe.",
27
- "So: register the aircraft, set its rate, keep its maintenance and its logbook current. Everything downstream reads from this record.",
43
+ // ── Why this course exists ──────────────────────────────────────────────
44
+ `${STUDENT_NAME} walked into my school on Monday. She's ${MAYA.ageSpoken}, ${MAYA.job}, ${MAYA.experience}. She wants ${MAYA.goal}.`,
45
+ `I don't have a course she can join, so I'm building the one she'll fly.`,
46
+
47
+ // ── The course ──────────────────────────────────────────────────────────
48
+ `The ${COURSE.name}. That's the license itself fly a small plane, day or night, carry passengers, just not for money.`,
49
+ `Certificate type, ${COURSE.certificate}. That's what she walks out with.`,
50
+ `${MAYA.short}'s on night shifts and can't hold to a fixed school timetable, so — ${COURSE.regulation}. It lets her fly when she's free. Part 141 would have locked her into my schedule.`,
51
+ `I'm setting ${COURSE.hoursSpoken} hours. The FAA minimum is forty and nobody finishes in forty — fifty-five is what my students actually take, and I'd rather quote ${MAYA.short} a number she'll hit.`,
52
+ `${COURSE.category}. She'll train in the one-seventy-twos.`,
53
+
54
+ // ── The syllabus, in the order she flies it ─────────────────────────────
55
+ `Now the syllabus. Five stages, in the order ${MAYA.short} flies them.`,
56
+ `${STAGES[0].name} — weather, regulations, navigation, radio work. All on the ground, ${STAGES[0].spoken} hours of it, before she touches an airplane.`,
57
+ `${STAGES[1].name}. Takeoffs, landings, stalls, emergencies — ${STAGES[1].spoken} hours of dual, meaning ${INSTRUCTOR.short} is beside her the whole time.`,
58
+ `${STAGES[2].name}. ${STAGES[2].spoken} hours, and the first of them is the day ${MAYA.short} takes off by herself. It needs my endorsement — I sign that off, nobody else.`,
59
+ `${STAGES[3].name}. ${STAGES[3].spoken} hours of long flights to airports she has never seen, navigating there and back.`,
60
+ `${STAGES[4].name}. The last ${STAGES[4].spoken} hours, flying to the standard an examiner will hold her to.`,
61
+
62
+ // ── The products — everything a charge attaches to ──────────────────────
63
+ `Now what ${MAYA.short} actually pays for. Every chargeable thing is its own product, and a course is the sum of the products on it.`,
64
+ `The airplane. ${PLANE.name}, ${SPOKEN.aircraftRate} an hour, wet — fuel included, so ${MAYA.short} isn't doing arithmetic after every lesson.`,
65
+ `${CFI.name}, ${SPOKEN.cfiRate} an hour. That's ${INSTRUCTOR.short} in the right seat, and it bills whether they fly or sit out a weather delay.`,
66
+ `${GROUND.name}, ${SPOKEN.groundRate}. Cheaper, because there's no airplane on the clock.`,
67
+ `And the ${KIT.name} — ${SPOKEN.kitPrice}, one time. Her books, charts, plotter and headset, bought on day one.`,
68
+ `All four go onto the course.`,
69
+ `So an hour of dual bills ${MAYA.short} ${SPOKEN.dualHourTotal} — ${SPOKEN.aircraftRate} for the airplane, ${SPOKEN.cfiRate} for ${INSTRUCTOR.short}. Her ${COURSE.hoursSpoken} hours come to ${SPOKEN.courseTotal}, and that's the number I quote her.`,
70
+ `The ${COURSE.name} is open for enrollment.`,
71
+
72
+ // ── Maya ────────────────────────────────────────────────────────────────
73
+ `Now ${MAYA.short} herself.`,
74
+ `She's asleep by eight most nights, so — ${MAYA.availability}. That's all she has.`,
75
+ `Into the ${COURSE.name}. She's the first one in it.`,
76
+ `${INSTRUCTOR.name} takes her. He ${INSTRUCTOR.why}.`,
77
+ `Her first lesson: ${FIRST_LESSON.day}, ${FIRST_LESSON.timeSpoken}, in ${AIRCRAFT.tailSpoken}. Ground School, hour one.`,
78
+ `That's ${MAYA.short}'s ${COURSE.hoursSpoken} hours laid out in front of her. ${FIRST_LESSON.day} morning she starts the first one.`,
28
79
  ];
@@ -1,52 +1,143 @@
1
1
  /**
2
- * TEMPLATE — one continuous recorded walkthrough.
2
+ * TEMPLATE — one continuous recorded walkthrough, following a named cast.
3
3
  *
4
4
  * One sentence, one thing on screen, in the same order as the .lines.mjs file.
5
5
  * The step's LENGTH comes from the measured audio; nothing here sets a duration
6
6
  * except the settle waits after a navigation.
7
7
  *
8
- * highlight(x) outline it, mouse stays put (naming something)
9
- * click(x) mouse glides to it and clicks (operating something)
10
- * goTo(url) navigate (jumping elsewhere)
11
- * none() no target ONLY for a genuinely abstract line
8
+ * highlight(x) outline it, mouse stays put (naming something)
9
+ * click(x) mouse glides to it and clicks (operating something)
10
+ * enter(x, value) type a REAL value into a field (entering data)
11
+ * choose(x, option) OPEN a dropdown and pick (making a decision)
12
+ * goTo(url) navigate (jumping elsewhere)
13
+ * none() no target — ONLY for a genuinely abstract line
14
+ *
15
+ * EVERY DROPDOWN USES choose(), NEVER highlight(). A select that is merely
16
+ * outlined shows nothing: the viewer cannot see what the alternatives were, or
17
+ * that a choice was made at all. The choice IS the story — it is where Maya's
18
+ * situation becomes Tyler's decision.
19
+ *
20
+ * VALUES COME FROM cast.mjs, never retyped as literals here. A name in two
21
+ * places will eventually disagree with itself, and then the narration says one
22
+ * thing while the screen shows another.
12
23
  *
13
24
  * AVOID highlight() IMMEDIATELY FOLLOWED BY goTo(). On camera that reads as
14
- * "they clicked that thing and it took us here" — and they did not. Click the
15
- * real navigation, or park the cursor somewhere neutral first.
25
+ * "they clicked that thing and it took us here" — and they did not.
16
26
  */
17
27
  import { test } from '@playwright/test';
18
28
  import { loadNarration } from './manifest';
19
29
  import { LOC, signInAsAdmin } from './signin';
20
- import { click, highlight, installOverlay, none, startRun, step, writeRunLog } from './runtime';
30
+ import { AIRCRAFT, COURSE, INSTRUCTOR, MAYA, PRODUCTS, STAGES, STUDENT_NAME } from './cast.mjs';
31
+ import {
32
+ act, choose, click, enter, highlight, installOverlay, none, startRun, step, writeRunLog,
33
+ } from './runtime';
21
34
 
22
35
  const NAME = 'example';
23
36
  const { ready, audio: AUDIO, L } = loadNarration(NAME);
37
+ const [PLANE, CFI, GROUND, KIT] = PRODUCTS;
38
+
39
+ /** Settle after a navigation. 6-8s, not 5 — a 5s read shows a populated page as empty. */
40
+ const SETTLE = 7000;
24
41
 
25
42
  test(`walkthrough — ${NAME}`, async ({ page }) => {
26
43
  // A spec whose audio is not rendered SKIPS. Playwright imports every spec
27
44
  // before applying --grep, so throwing here takes down the whole run.
28
45
  test.skip(!ready, 'narration not rendered yet');
29
- test.setTimeout(20 * 60_000);
46
+ test.setTimeout(25 * 60_000);
30
47
 
31
48
  startRun(AUDIO as never);
32
49
  await signInAsAdmin(page);
33
50
 
34
- await page.goto(`/location/${LOC}/{route}`, { waitUntil: 'domcontentloaded' });
35
- await page.waitForTimeout(6000); // 6-8s, not 5 — see the settle note
36
- await installOverlay(page);
51
+ const settle = async (ms = SETTLE) => {
52
+ await page.waitForTimeout(ms);
53
+ await installOverlay(page); // the overlay does not survive a navigation
54
+ };
55
+ const field = (label: string) => page.getByLabel(new RegExp(label, 'i'));
56
+ const button = (name: string) => page.getByRole('button', { name: new RegExp(name, 'i') });
37
57
 
38
- await step(page, L[0], none());
39
- await step(page, L[1], highlight(page.getByText(/{Card Title}/i)));
40
- await step(page, L[2], highlight(page.getByText(/{List Heading}/i)));
41
- await step(page, L[3], highlight(page.getByText(/{Rate}/i).first()));
58
+ // ── Why this course exists ──────────────────────────────────────────────
59
+ await page.goto(`/location/${LOC}/{programs-route}`, { waitUntil: 'domcontentloaded' });
60
+ await settle();
42
61
 
43
- await step(page, L[4], click(page.getByRole('button', { name: /{Open Record}/i })));
44
- // Re-install the overlay after every navigation the cursor and highlight
45
- // layers are injected into the page and do not survive one.
46
- await page.waitForTimeout(6000);
62
+ await step(page, L[0], none()); // Maya walked in
63
+ await step(page, L[1], click(button('{New Program}'))); // so I'm building it
64
+ await settle(2500);
65
+
66
+ // ── The course ──────────────────────────────────────────────────────────
67
+ await step(page, L[2], enter(field('{Program Name}'), COURSE.name));
68
+ await step(page, L[3], choose(field('{Certificate}'), COURSE.certificate));
69
+ await step(page, L[4], choose(field('{Regulation}'), COURSE.regulation));
70
+ await step(page, L[5], enter(field('{Total Hours}'), COURSE.hours));
71
+ await step(page, L[6], choose(field('{Category}'), COURSE.category));
72
+
73
+ // ── The syllabus — one stage per line, each named, typed and picked ─────
74
+ await step(page, L[7], click(button('{Add Stage}')));
75
+ await settle(2000);
76
+
77
+ for (const [i, stage] of STAGES.entries()) {
78
+ // Each stage is one narrated beat: name it, and the type is what the
79
+ // sentence is actually about (Ground vs Dual vs Solo).
80
+ await step(page, L[8 + i], enter(field('{Stage Name}'), stage.name));
81
+ // act(), not a bare await — choose() only BUILDS an action; awaiting one
82
+ // does nothing and leaves the field empty.
83
+ await act(page, choose(field('{Stage Type}'), stage.type));
84
+ await act(page, enter(field('{Stage Hours}'), stage.hours));
85
+ if (stage.endorsement) await page.getByLabel(/{endorsement}/i).check().catch(() => {});
86
+ await button('{Add Stage}').click().catch(() => {});
87
+ await page.waitForTimeout(800);
88
+ }
47
89
  await installOverlay(page);
48
90
 
49
- await step(page, L[5], none());
91
+ // ── The products — everything a charge attaches to ──────────────────────
92
+ await page.goto(`/location/${LOC}/{products-route}`, { waitUntil: 'domcontentloaded' });
93
+ await settle();
94
+
95
+ await step(page, L[13], none()); // what Maya pays for
96
+
97
+ // Four products, created on camera. L[14..17] each narrate one.
98
+ for (const [i, product] of PRODUCTS.entries()) {
99
+ await step(page, L[14 + i], click(button('{New Product}')));
100
+ await settle(2000);
101
+ await act(page, enter(field('{Product Name}'), product.name));
102
+ await act(page, choose(field('{Product Type}'), product.type));
103
+ await act(page, enter(field('{Price}'), product.price));
104
+ await act(page, choose(field('{Billing}'), product.unit));
105
+ await button('^Save').click().catch(() => {});
106
+ await settle(3000);
107
+ }
108
+
109
+ // Attach all four to the course — the sum of these IS the invoice.
110
+ await page.goto(`/location/${LOC}/{programs-route}`, { waitUntil: 'domcontentloaded' });
111
+ await settle();
112
+ await step(page, L[18], choose(field('{Products}'), PLANE.name));
113
+ for (const p of [CFI, GROUND, KIT]) await act(page, choose(field('{Products}'), p.name));
114
+
115
+ await step(page, L[19], highlight(page.getByText(/{Total}|{Estimated}/i).first()));
116
+ await step(page, L[20], click(button('{Publish}')));
117
+ await settle();
118
+
119
+ // ── Maya ────────────────────────────────────────────────────────────────
120
+ await page.goto(`/location/${LOC}/{students-route}`, { waitUntil: 'domcontentloaded' });
121
+ await settle();
122
+
123
+ await step(page, L[21], click(button('{Add Student}')));
124
+ await settle(2500);
125
+ await act(page, enter(field('{Full Name}'), STUDENT_NAME));
126
+ await act(page, enter(field('{Email}'), MAYA.email));
127
+ await act(page, enter(field('{Phone}'), MAYA.phone));
128
+
129
+ await step(page, L[22], choose(field('{Availability}'), MAYA.availability));
130
+ await step(page, L[23], choose(field('{Enroll}'), COURSE.name));
131
+ await step(page, L[24], choose(field('{Instructor}'), INSTRUCTOR.name));
132
+ await button('^Save').click().catch(() => {});
133
+ await settle();
134
+
135
+ // ── Her first lesson ────────────────────────────────────────────────────
136
+ await page.goto(`/location/${LOC}/{schedule-route}`, { waitUntil: 'domcontentloaded' });
137
+ await settle();
138
+
139
+ await step(page, L[25], highlight(page.getByText(new RegExp(AIRCRAFT.tail, 'i')).first()));
140
+ await step(page, L[26], highlight(page.getByText(new RegExp(STUDENT_NAME, 'i')).first()));
50
141
 
51
142
  // The step log is the proof of what was on screen when. The mux reads it.
52
143
  writeRunLog(NAME);
@@ -29,7 +29,7 @@ export interface StepLog {
29
29
  audioMs: number;
30
30
  route: string;
31
31
  focus: string;
32
- action: 'highlight' | 'click' | 'goto' | 'none';
32
+ action: 'highlight' | 'click' | 'type' | 'choose' | 'goto' | 'none';
33
33
  }
34
34
 
35
35
  const log: StepLog[] = [];
@@ -134,11 +134,34 @@ async function reveal(page: Page, loc: Locator): Promise<Locator | null> {
134
134
  export type Action =
135
135
  | { kind: 'highlight'; loc: Locator }
136
136
  | { kind: 'click'; loc: Locator }
137
+ | { kind: 'type'; loc: Locator; value: string }
138
+ | { kind: 'choose'; loc: Locator; option: string }
137
139
  | { kind: 'goto'; url: string }
138
140
  | { kind: 'none' };
139
141
 
140
142
  export const highlight = (loc: Locator): Action => ({ kind: 'highlight', loc });
141
143
  export const click = (loc: Locator): Action => ({ kind: 'click', loc });
144
+ /**
145
+ * Type a REAL value into a field, visibly, while the sentence says it.
146
+ *
147
+ * This is the action that makes a demo concrete instead of clinical. A sentence
148
+ * explaining what a field is for, over an empty field, teaches nothing — so the
149
+ * narration says the value and this puts it on screen, character by character
150
+ * so the viewer can read it going in.
151
+ */
152
+ // Named `enter`, not `type` — `import { type X }` is TypeScript's type-only
153
+ // import syntax, so an exported `type` is a trap for whoever imports it.
154
+ export const enter = (loc: Locator, value: string): Action => ({ kind: 'type', loc, value });
155
+ /**
156
+ * Open a dropdown, let the options be READ, then pick one.
157
+ *
158
+ * The most informative moment in a create-flow is the choice: it is where the
159
+ * specific decision becomes visible. A highlighted select with a value already
160
+ * in it shows nothing — the viewer cannot tell what the alternatives were, or
161
+ * that a choice was made at all. So this opens the list, holds it open long
162
+ * enough to read, and then clicks the option.
163
+ */
164
+ export const choose = (loc: Locator, option: string): Action => ({ kind: 'choose', loc, option });
142
165
  export const goTo = (url: string): Action => ({ kind: 'goto', url });
143
166
  export const none = (): Action => ({ kind: 'none' });
144
167
 
@@ -151,6 +174,85 @@ export const none = (): Action => ({ kind: 'none' });
151
174
  * eats the whole step the rest is zero and the timeline simply shifts; the
152
175
  * audio track is assembled afterward from these logged timings, never guessed.
153
176
  */
177
+ /**
178
+ * Carry out one action and report what it landed on.
179
+ *
180
+ * Shared by step() (a narrated beat) and act() (a field the narration does not
181
+ * mention individually). `context` is only used to make an error legible.
182
+ */
183
+ async function perform(page: Page, action: Action, context: string): Promise<string> {
184
+ if (action.kind === 'none') return '';
185
+
186
+ if (action.kind === 'goto') {
187
+ await page.goto(action.url, { waitUntil: 'domcontentloaded' }).catch(() => {});
188
+ await installOverlay(page);
189
+ return action.url;
190
+ }
191
+
192
+ const target = await reveal(page, action.loc);
193
+ if (!target) {
194
+ throw new Error(
195
+ `[walkthrough] target not visible for: "${context.slice(0, 70)}"\n` +
196
+ ' Every sentence must point at something that is actually on screen.',
197
+ );
198
+ }
199
+
200
+ let focus = (await target.innerText().catch(() => ''))?.slice(0, 40) ?? '';
201
+ const box = await target.boundingBox();
202
+ if (!box) return focus;
203
+
204
+ const cx = box.x + box.width / 2;
205
+ const cy = box.y + box.height / 2;
206
+ await ring(page, box);
207
+ await glide(page, cx, cy, 700);
208
+
209
+ if (action.kind === 'click') {
210
+ await page.waitForTimeout(420);
211
+ await target.click({ timeout: 15_000 }).catch(() => {});
212
+ await clearRing(page);
213
+ await installOverlay(page);
214
+ } else if (action.kind === 'choose') {
215
+ // Open it, HOLD so the options are readable, then pick. A native <select>
216
+ // cannot be opened visibly, so fall back to selectOption for one.
217
+ await page.waitForTimeout(300);
218
+ await target.click({ timeout: 15_000 }).catch(() => {});
219
+ await page.waitForTimeout(900);
220
+ const tag = await target.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
221
+ if (tag === 'select') {
222
+ await target.selectOption({ label: action.option }).catch(() => {});
223
+ } else {
224
+ const opt = page
225
+ .getByRole('option', { name: action.option, exact: false })
226
+ .or(page.getByRole('menuitem', { name: action.option, exact: false }))
227
+ .first();
228
+ await opt.click({ timeout: 10_000 }).catch(() => {});
229
+ }
230
+ focus = action.option.slice(0, 40);
231
+ await clearRing(page);
232
+ } else if (action.kind === 'type') {
233
+ // Click in, clear, then type at a readable pace — fill() makes the value
234
+ // appear instantly, which reads as a screenshot rather than data entry.
235
+ await page.waitForTimeout(300);
236
+ await target.click({ timeout: 15_000 }).catch(() => {});
237
+ await target.fill('').catch(() => {});
238
+ await target.pressSequentially(action.value, { delay: 45 }).catch(() => {});
239
+ focus = action.value.slice(0, 40);
240
+ }
241
+ return focus;
242
+ }
243
+
244
+ /**
245
+ * Perform an action OUTSIDE a narrated step — for fields a sentence does not
246
+ * mention individually but which still have to be filled.
247
+ *
248
+ * choose() and enter() only BUILD an action; they do nothing on their own, and
249
+ * `await choose(...)` is a no-op that silently leaves the field empty. step()
250
+ * performs one action; use act() for the rest.
251
+ */
252
+ export async function act(page: Page, action: Action): Promise<void> {
253
+ await perform(page, action, '(unnarrated field)');
254
+ }
255
+
154
256
  export async function step(
155
257
  page: Page,
156
258
  narration: string,
@@ -160,35 +262,7 @@ export async function step(
160
262
  if (!clip) throw new Error(`[walkthrough] no audio for: "${narration.slice(0, 70)}"`);
161
263
 
162
264
  const startMs = now();
163
- let focus = '';
164
-
165
- if (action.kind === 'goto') {
166
- await page.goto(action.url, { waitUntil: 'domcontentloaded' }).catch(() => {});
167
- await installOverlay(page);
168
- focus = action.url;
169
- } else if (action.kind !== 'none') {
170
- const target = await reveal(page, action.loc);
171
- if (!target) {
172
- throw new Error(
173
- `[walkthrough] target not visible for: "${narration.slice(0, 70)}"\n` +
174
- ' Every sentence must point at something that is actually on screen.',
175
- );
176
- }
177
- focus = (await target.innerText().catch(() => ''))?.slice(0, 40) ?? '';
178
- const box = await target.boundingBox();
179
- if (box) {
180
- const cx = box.x + box.width / 2;
181
- const cy = box.y + box.height / 2;
182
- await ring(page, box);
183
- await glide(page, cx, cy, 700);
184
- if (action.kind === 'click') {
185
- await page.waitForTimeout(420);
186
- await target.click({ timeout: 15_000 }).catch(() => {});
187
- await clearRing(page);
188
- await installOverlay(page);
189
- }
190
- }
191
- }
265
+ const focus = await perform(page, action, narration);
192
266
 
193
267
  // Hold for whatever is left of the spoken sentence.
194
268
  const spent = now() - startMs;