@tekyzinc/gsd-t 5.16.10 → 5.16.11
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 +62 -0
- package/README.md +1 -1
- package/commands/gsd-t-demo-videos.md +211 -73
- package/package.json +1 -1
- package/templates/demo-videos/README.md +3 -2
- package/templates/demo-videos/e2e/cast.mjs +104 -0
- package/templates/demo-videos/e2e/example.lines.mjs +66 -15
- package/templates/demo-videos/e2e/example.spec.ts +112 -21
- package/templates/demo-videos/e2e/runtime.ts +104 -30
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.16.11] - 2026-08-27
|
|
6
|
+
|
|
7
|
+
### Fixed — the voice fix was the wrong fix, and the gate was measuring the wrong thing
|
|
8
|
+
|
|
9
|
+
Both corrections came from running the shipped pipeline against real work, and
|
|
10
|
+
both invalidate advice published hours earlier in 5.16.10.
|
|
11
|
+
|
|
12
|
+
**Pick a text-to-speech service whose voice is a fixed trained speaker.** The
|
|
13
|
+
source run had fought drift with batching — 8 sentences per request so one
|
|
14
|
+
request meant one performance, audio split back apart on silences, splits
|
|
15
|
+
verified, whole takes re-rendered when they measured as drifted. All of that was
|
|
16
|
+
mitigation for a property of the wrong kind of service. A language model's audio
|
|
17
|
+
output treats a voice name as a style hint it re-interprets on every request, so
|
|
18
|
+
the narrator genuinely changes part-way through a video; batching reduces how
|
|
19
|
+
often that happens and cannot stop it, because a batch boundary is still a
|
|
20
|
+
boundary between two readings. Moving to a dedicated speech service, where a
|
|
21
|
+
voice id is a fixed trained speaker, ended it in one change — and took batch
|
|
22
|
+
boundaries, silence splitting, miscut clips and per-model daily quotas with it.
|
|
23
|
+
One sentence per request became both the simple thing and the correct thing. The
|
|
24
|
+
batching guidance is kept, demoted to what to do when you cannot switch.
|
|
25
|
+
|
|
26
|
+
**Gate on mean pitch, not within-video pitch spread.** The published gate failed
|
|
27
|
+
**nine of the twelve videos its own project had already shipped** — every one
|
|
28
|
+
confirmed by ear as a single steady narrator. A ±35 Hz spread threshold is right
|
|
29
|
+
for a language-model TTS, where a wide spread means a different reading, and
|
|
30
|
+
wrong for a fixed speaker, where it measures ordinary sentence intonation: a
|
|
31
|
+
question rising, a list falling, a short line sitting higher. Flattening that
|
|
32
|
+
would make the narration robotic. The speaker-identity signal is the mean, which
|
|
33
|
+
across those twelve videos sat inside a 7 Hz band. So the gate now checks the
|
|
34
|
+
mean against a calibrated band and reports the spread without gating it — ten of
|
|
35
|
+
twelve pass, and the two that still fail do so on volume, which is real.
|
|
36
|
+
|
|
37
|
+
The rule this produces, now in the command: **calibrate a gate against output a
|
|
38
|
+
human has already approved before trusting it.** A gate that fails most of your
|
|
39
|
+
known-good work is measuring the wrong thing, and believing it costs you
|
|
40
|
+
re-rendering audio that was already correct.
|
|
41
|
+
|
|
42
|
+
**Narration lines must be full sentences of 8 words or more.** Integrated
|
|
43
|
+
loudness needs enough audio to measure against; a two-word clip ("Create
|
|
44
|
+
Curriculum.") lands off target, blows the video's volume spread and trips the
|
|
45
|
+
miscut check. This cost two full render cycles before it was diagnosed. When a
|
|
46
|
+
step needs an action the narration does not describe, run it untimed rather than
|
|
47
|
+
inventing a stub line.
|
|
48
|
+
|
|
49
|
+
**Templates:** `choose()` opens a dropdown, holds it open long enough to read,
|
|
50
|
+
and picks — the choice is the most informative moment in a create-flow, and a
|
|
51
|
+
highlighted select shows the viewer nothing. `enter()` types a value visibly
|
|
52
|
+
rather than filling it instantly. `act()` performs either outside a narrated
|
|
53
|
+
beat, because both only build an action and a bare `await` on one silently does
|
|
54
|
+
nothing. New `cast.mjs` carries every name said aloud in one place.
|
|
55
|
+
|
|
56
|
+
**Narration guidance rewritten around two woven stories** — the operator's, told
|
|
57
|
+
in their own voice and shown on screen, and the customer's, which is why every
|
|
58
|
+
value typed is that value. The test: a sentence that survives with the names
|
|
59
|
+
removed is explaining the software, not telling the story.
|
|
60
|
+
|
|
61
|
+
- `commands/gsd-t-demo-videos.md`: Stage 1 rewritten; cast step gains the
|
|
62
|
+
two-story weave, the products/pricing rule and the dropdown mandate
|
|
63
|
+
- `templates/demo-videos/e2e/cast.mjs`: new
|
|
64
|
+
- `templates/demo-videos/e2e/runtime.ts`: `enter()`, `choose()`, `act()`
|
|
65
|
+
- `templates/demo-videos/e2e/example.{lines.mjs,spec.ts}`: rewritten as a story
|
|
66
|
+
|
|
5
67
|
## [5.16.10] - 2026-08-27
|
|
6
68
|
|
|
7
69
|
### 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.
|
|
3
|
+
**v5.16.11** - 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.
|
|
@@ -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
|
|
43
|
-
| A **
|
|
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
|
-
|
|
122
|
-
|
|
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 —
|
|
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
|
|
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
|
|
175
|
-
|
|
176
|
-
**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
>
|
|
197
|
-
>
|
|
198
|
-
>
|
|
199
|
-
>
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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 —
|
|
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
|
|
359
|
-
scripts/walkthrough-voice
|
|
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.
|
|
3
|
+
"version": "5.16.11",
|
|
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/
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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)
|
|
9
|
-
* click(x)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
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 {
|
|
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(
|
|
46
|
+
test.setTimeout(25 * 60_000);
|
|
30
47
|
|
|
31
48
|
startRun(AUDIO as never);
|
|
32
49
|
await signInAsAdmin(page);
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
await
|
|
40
|
-
await
|
|
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[
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|