qualia-framework 4.1.1 → 4.4.0

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.
Files changed (43) hide show
  1. package/README.md +15 -11
  2. package/agents/builder.md +28 -0
  3. package/agents/research-synthesizer.md +7 -0
  4. package/bin/agent-runs.js +233 -0
  5. package/bin/cli.js +355 -16
  6. package/bin/install.js +87 -6
  7. package/bin/knowledge-flush.js +164 -0
  8. package/bin/knowledge.js +317 -0
  9. package/bin/plan-contract.js +220 -0
  10. package/bin/state.js +15 -9
  11. package/docs/agent-runs.md +273 -0
  12. package/docs/journey-demo.html +1008 -0
  13. package/docs/plan-contract.md +321 -0
  14. package/docs/reviews/v4.1.0-audit.html +1488 -0
  15. package/docs/reviews/v4.1.0-audit.md +263 -0
  16. package/hooks/auto-update.js +3 -7
  17. package/hooks/git-guardrails.js +167 -0
  18. package/hooks/pre-compact.js +22 -11
  19. package/hooks/pre-deploy-gate.js +16 -2
  20. package/hooks/pre-push.js +22 -2
  21. package/hooks/stop-session-log.js +180 -0
  22. package/package.json +8 -2
  23. package/skills/qualia-build/SKILL.md +5 -5
  24. package/skills/qualia-debug/SKILL.md +1 -1
  25. package/skills/qualia-design/SKILL.md +15 -0
  26. package/skills/qualia-flush/SKILL.md +200 -0
  27. package/skills/qualia-learn/SKILL.md +47 -37
  28. package/skills/qualia-new/SKILL.md +1 -1
  29. package/skills/qualia-plan/SKILL.md +3 -2
  30. package/skills/qualia-postmortem/SKILL.md +238 -0
  31. package/skills/qualia-quick/SKILL.md +1 -1
  32. package/skills/qualia-report/SKILL.md +1 -1
  33. package/skills/qualia-review/SKILL.md +3 -2
  34. package/skills/qualia-ship/SKILL.md +12 -10
  35. package/skills/qualia-verify/SKILL.md +60 -0
  36. package/templates/help.html +13 -7
  37. package/templates/knowledge/agents.md +71 -0
  38. package/templates/knowledge/index.md +47 -0
  39. package/tests/bin.test.sh +322 -12
  40. package/tests/hooks.test.sh +131 -20
  41. package/tests/lib.test.sh +217 -0
  42. package/tests/runner.js +103 -77
  43. package/tests/state.test.sh +4 -3
@@ -0,0 +1,1008 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Qualia Framework — A Project's Journey</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ --bg: #04060a;
13
+ --bg-2: #080c12;
14
+ --surface: #0d1218;
15
+ --surface-2: #131a22;
16
+ --line: #1c232c;
17
+ --line-strong: #2a333d;
18
+ --ink: #eaeff5;
19
+ --ink-soft: #b4bec9;
20
+ --ink-mute: #6e7886;
21
+ --brand: #00ced9;
22
+ --brand-2: #5de3eb;
23
+ --accent: #ffbf5e;
24
+ --violet: #a58bff;
25
+ --ok: #6ee7a8;
26
+ --ease-std: cubic-bezier(0.4, 0, 0.2, 1);
27
+ --ease-dec: cubic-bezier(0, 0, 0.2, 1);
28
+ --ease-sp: cubic-bezier(0.34, 1.56, 0.64, 1);
29
+ }
30
+
31
+ *, *::before, *::after { box-sizing: border-box; }
32
+ html, body { margin: 0; padding: 0; }
33
+ html { scroll-behavior: smooth; }
34
+ body {
35
+ font-family: "IBM Plex Sans", system-ui, sans-serif;
36
+ background: var(--bg);
37
+ color: var(--ink);
38
+ -webkit-font-smoothing: antialiased;
39
+ overflow-x: hidden;
40
+ min-height: 100vh;
41
+ }
42
+
43
+ /* ambient */
44
+ body::before {
45
+ content: "";
46
+ position: fixed; inset: 0;
47
+ background:
48
+ radial-gradient(1400px 800px at 75% -15%, rgba(0, 206, 217, 0.18), transparent 55%),
49
+ radial-gradient(1000px 700px at 15% 115%, rgba(165, 139, 255, 0.08), transparent 60%);
50
+ pointer-events: none; z-index: 0;
51
+ }
52
+ body::after {
53
+ content: "";
54
+ position: fixed; inset: 0;
55
+ background-image:
56
+ linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
57
+ linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
58
+ background-size: 56px 56px;
59
+ mask-image: radial-gradient(ellipse at center, black 25%, transparent 85%);
60
+ pointer-events: none; z-index: 0;
61
+ }
62
+
63
+ h1, h2, h3 {
64
+ font-family: "Fraunces", Georgia, serif;
65
+ font-weight: 500;
66
+ letter-spacing: -0.02em;
67
+ line-height: 1.1;
68
+ margin: 0;
69
+ }
70
+
71
+ code.inline {
72
+ font-family: "JetBrains Mono", monospace;
73
+ background: var(--surface-2);
74
+ border: 1px solid var(--line);
75
+ padding: 0.1em 0.45em;
76
+ border-radius: 4px;
77
+ color: var(--brand);
78
+ font-size: 0.88em;
79
+ }
80
+
81
+ .sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); }
82
+
83
+ /* layout */
84
+ .stage-wrap {
85
+ position: relative;
86
+ z-index: 1;
87
+ min-height: 100vh;
88
+ display: grid;
89
+ grid-template-rows: auto 1fr auto;
90
+ padding: clamp(1rem, 3vw, 2rem) clamp(1rem, 4vw, 3rem);
91
+ gap: clamp(1rem, 2vw, 1.5rem);
92
+ }
93
+
94
+ /* HEADER BAR */
95
+ .top {
96
+ display: flex; align-items: center; justify-content: space-between; gap: 1rem;
97
+ flex-wrap: wrap;
98
+ }
99
+ .brand {
100
+ display: inline-flex; align-items: center; gap: 10px;
101
+ font-family: "Fraunces", serif; font-weight: 500;
102
+ color: var(--ink); text-decoration: none;
103
+ font-size: 1.05rem;
104
+ letter-spacing: -0.01em;
105
+ }
106
+ .brand-mark { color: var(--brand); display: grid; place-items: center; }
107
+ .project-chip {
108
+ display: inline-flex; align-items: center; gap: 0.6rem;
109
+ padding: 0.45rem 0.75rem;
110
+ background: var(--surface);
111
+ border: 1px solid var(--line);
112
+ border-radius: 999px;
113
+ font-family: "JetBrains Mono", monospace;
114
+ font-size: 0.76rem;
115
+ color: var(--ink-soft);
116
+ }
117
+ .project-chip .pulse {
118
+ width: 8px; height: 8px; border-radius: 999px;
119
+ background: var(--brand);
120
+ box-shadow: 0 0 0 0 rgba(0,206,217,0.6);
121
+ animation: livePulse 1.8s infinite;
122
+ }
123
+ @keyframes livePulse {
124
+ 0% { box-shadow: 0 0 0 0 rgba(0,206,217,0.5); }
125
+ 70% { box-shadow: 0 0 0 10px rgba(0,206,217,0); }
126
+ 100% { box-shadow: 0 0 0 0 rgba(0,206,217,0); }
127
+ }
128
+
129
+ /* MAIN STAGE */
130
+ .stage {
131
+ position: relative;
132
+ display: grid;
133
+ grid-template-columns: 1fr;
134
+ grid-template-rows: auto 1fr;
135
+ gap: 1.5rem;
136
+ align-items: start;
137
+ }
138
+ @media (min-width: 1040px) {
139
+ .stage { grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr); grid-template-rows: 1fr; }
140
+ }
141
+
142
+ /* Title / narration column */
143
+ .narration {
144
+ display: flex; flex-direction: column; gap: 1rem;
145
+ }
146
+ .eyebrow {
147
+ font-family: "JetBrains Mono", monospace;
148
+ font-size: 0.72rem;
149
+ color: var(--brand);
150
+ letter-spacing: 0.22em;
151
+ text-transform: uppercase;
152
+ }
153
+ .eyebrow .chapter {
154
+ color: var(--ink-mute);
155
+ margin-left: 0.6rem;
156
+ letter-spacing: 0.1em;
157
+ }
158
+ h1.journey-title {
159
+ font-size: clamp(2rem, 1rem + 3.5vw, 4rem);
160
+ font-variation-settings: "opsz" 144;
161
+ }
162
+ h1.journey-title .accent {
163
+ color: var(--brand);
164
+ font-style: italic;
165
+ font-weight: 400;
166
+ }
167
+ .narration p {
168
+ color: var(--ink-soft);
169
+ font-size: clamp(0.95rem, 0.6rem + 0.4vw, 1.05rem);
170
+ max-width: 56ch;
171
+ line-height: 1.6;
172
+ margin: 0;
173
+ min-height: 3.2em;
174
+ }
175
+ .step-badge {
176
+ display: inline-flex; align-items: center; gap: 0.65rem;
177
+ padding: 0.4rem 0.75rem;
178
+ border: 1px solid var(--line);
179
+ background: var(--surface);
180
+ border-radius: 8px;
181
+ font-family: "JetBrains Mono", monospace;
182
+ font-size: 0.8rem;
183
+ color: var(--ink);
184
+ width: fit-content;
185
+ transition: border-color 300ms;
186
+ }
187
+ .step-badge.live { border-color: var(--brand); color: var(--brand); }
188
+ .step-badge .tag { color: var(--ink-mute); }
189
+
190
+ /* Terminal (right column) */
191
+ .term {
192
+ background: var(--surface);
193
+ border: 1px solid var(--line);
194
+ border-radius: 12px;
195
+ overflow: hidden;
196
+ box-shadow: 0 40px 80px -50px rgba(0, 206, 217, 0.3);
197
+ display: flex; flex-direction: column;
198
+ min-height: 280px;
199
+ }
200
+ .term-head {
201
+ display: flex; align-items: center; gap: 0.4rem;
202
+ padding: 10px 14px;
203
+ background: var(--surface-2);
204
+ border-bottom: 1px solid var(--line);
205
+ font-family: "JetBrains Mono", monospace;
206
+ font-size: 0.74rem;
207
+ color: var(--ink-mute);
208
+ }
209
+ .term-dot { width: 9px; height: 9px; border-radius: 999px; }
210
+ .term-dot.r { background: #f87171; }
211
+ .term-dot.y { background: #ffbf5e; }
212
+ .term-dot.g { background: #6ee7a8; }
213
+ .term-title { margin-left: 0.45rem; }
214
+ .term-body {
215
+ padding: 1rem 1.2rem;
216
+ font-family: "JetBrains Mono", monospace;
217
+ font-size: 0.78rem;
218
+ line-height: 1.75;
219
+ color: var(--ink-soft);
220
+ flex: 1;
221
+ overflow: hidden;
222
+ position: relative;
223
+ }
224
+ .term-line { display: block; opacity: 0; transform: translateY(3px); }
225
+ .term-line.show { opacity: 1; transform: none; transition: opacity 280ms, transform 280ms; }
226
+ .t-prompt { color: var(--brand); }
227
+ .t-cmd { color: var(--ink); }
228
+ .t-ok { color: var(--ok); }
229
+ .t-warn { color: var(--accent); }
230
+ .t-mute { color: var(--ink-mute); }
231
+ .t-violet { color: var(--violet); }
232
+ .t-accent { color: var(--accent); }
233
+
234
+ /* JOURNEY TRACK (SVG) */
235
+ .track {
236
+ position: relative;
237
+ border: 1px solid var(--line);
238
+ border-radius: 20px;
239
+ background:
240
+ linear-gradient(180deg, var(--surface), var(--bg-2));
241
+ overflow: hidden;
242
+ }
243
+ .track svg {
244
+ display: block;
245
+ width: 100%;
246
+ height: clamp(320px, 44vh, 520px);
247
+ }
248
+ .track-footer {
249
+ border-top: 1px solid var(--line);
250
+ padding: 0.85rem 1.1rem;
251
+ display: flex; align-items: center; gap: 1rem;
252
+ flex-wrap: wrap;
253
+ }
254
+ .progress-label {
255
+ font-family: "JetBrains Mono", monospace;
256
+ font-size: 0.72rem;
257
+ color: var(--ink-mute);
258
+ letter-spacing: 0.12em;
259
+ text-transform: uppercase;
260
+ min-width: 90px;
261
+ }
262
+ .progress-bar {
263
+ flex: 1;
264
+ min-width: 160px;
265
+ height: 4px;
266
+ background: var(--line);
267
+ border-radius: 999px;
268
+ overflow: hidden;
269
+ }
270
+ .progress-fill {
271
+ height: 100%;
272
+ background: linear-gradient(90deg, var(--brand), var(--brand-2));
273
+ width: 0%;
274
+ transition: width 600ms var(--ease-std);
275
+ }
276
+ .controls { display: inline-flex; gap: 0.4rem; }
277
+ .btn {
278
+ background: var(--surface-2);
279
+ border: 1px solid var(--line);
280
+ color: var(--ink);
281
+ font: inherit;
282
+ font-family: "JetBrains Mono", monospace;
283
+ font-size: 0.76rem;
284
+ padding: 0.45rem 0.8rem;
285
+ border-radius: 6px;
286
+ cursor: pointer;
287
+ transition: border-color 150ms, color 150ms, transform 100ms;
288
+ }
289
+ .btn:hover { border-color: var(--brand); color: var(--brand); }
290
+ .btn:active { transform: scale(0.97); }
291
+ .btn[aria-pressed="true"] { border-color: var(--brand); color: var(--brand); background: rgba(0,206,217,0.08); }
292
+ .btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
293
+
294
+ /* BOTTOM PANEL */
295
+ .bottom {
296
+ display: grid;
297
+ grid-template-columns: 1fr;
298
+ gap: 1rem;
299
+ }
300
+ @media (min-width: 840px) { .bottom { grid-template-columns: repeat(4, 1fr); } }
301
+ .stat {
302
+ border: 1px solid var(--line);
303
+ background: var(--surface);
304
+ border-radius: 10px;
305
+ padding: 0.85rem 1rem;
306
+ min-height: 78px;
307
+ display: flex; flex-direction: column; gap: 0.25rem;
308
+ position: relative;
309
+ overflow: hidden;
310
+ transition: border-color 200ms;
311
+ }
312
+ .stat.live { border-color: var(--brand); }
313
+ .stat .k {
314
+ font-family: "JetBrains Mono", monospace;
315
+ font-size: 0.68rem;
316
+ color: var(--ink-mute);
317
+ letter-spacing: 0.14em;
318
+ text-transform: uppercase;
319
+ }
320
+ .stat .v {
321
+ font-family: "Fraunces", serif;
322
+ font-size: clamp(1.3rem, 0.7rem + 1vw, 1.75rem);
323
+ color: var(--ink);
324
+ }
325
+ .stat.live .v { color: var(--brand); }
326
+ .stat .hint {
327
+ font-family: "JetBrains Mono", monospace;
328
+ font-size: 0.72rem;
329
+ color: var(--ink-mute);
330
+ margin-top: auto;
331
+ }
332
+
333
+ /* SVG stage elements */
334
+ .station .ring {
335
+ fill: none;
336
+ stroke: var(--line-strong);
337
+ stroke-width: 1.4;
338
+ transition: stroke 400ms, stroke-width 400ms;
339
+ }
340
+ .station .core {
341
+ fill: var(--surface-2);
342
+ stroke: var(--line-strong);
343
+ stroke-width: 1.5;
344
+ transition: fill 400ms, stroke 400ms;
345
+ }
346
+ .station.passed .ring { stroke: var(--ok); opacity: 0.5; }
347
+ .station.passed .core { fill: rgba(110,231,168,0.15); stroke: var(--ok); }
348
+ .station.active .ring { stroke: var(--brand); stroke-width: 2; }
349
+ .station.active .core { fill: rgba(0,206,217,0.2); stroke: var(--brand); }
350
+ .station .label {
351
+ font-family: "JetBrains Mono", monospace;
352
+ font-size: 10px;
353
+ fill: var(--ink-mute);
354
+ text-anchor: middle;
355
+ letter-spacing: 0.1em;
356
+ transition: fill 400ms;
357
+ }
358
+ .station.active .label { fill: var(--brand); }
359
+ .station.passed .label { fill: var(--ok); opacity: 0.7; }
360
+ .station .cmd {
361
+ font-family: "IBM Plex Sans", sans-serif;
362
+ font-size: 11px;
363
+ fill: var(--ink-soft);
364
+ text-anchor: middle;
365
+ font-weight: 500;
366
+ transition: fill 400ms;
367
+ }
368
+ .station.active .cmd { fill: var(--ink); }
369
+
370
+ /* floating agent orbs */
371
+ .orb {
372
+ transform-origin: center;
373
+ }
374
+ .orb .body {
375
+ fill: var(--surface-2);
376
+ stroke: var(--brand);
377
+ stroke-width: 1.4;
378
+ filter: drop-shadow(0 0 10px rgba(0,206,217,0.35));
379
+ }
380
+ .orb .lbl {
381
+ font-family: "JetBrains Mono", monospace;
382
+ font-size: 9px;
383
+ fill: var(--brand);
384
+ text-anchor: middle;
385
+ }
386
+ .orb-fade-in { animation: orbIn 500ms var(--ease-sp) both; }
387
+ .orb-fade-out { animation: orbOut 400ms var(--ease-std) both; }
388
+ @keyframes orbIn { from { opacity: 0; transform: scale(0.3); } to { opacity: 1; transform: scale(1); } }
389
+ @keyframes orbOut { to { opacity: 0; transform: scale(0.3); } }
390
+
391
+ /* footer */
392
+ footer {
393
+ position: relative; z-index: 1;
394
+ padding: 1.5rem clamp(1rem, 4vw, 3rem);
395
+ color: var(--ink-mute);
396
+ font-size: 0.82rem;
397
+ text-align: center;
398
+ border-top: 1px solid var(--line);
399
+ }
400
+
401
+ /* reduced motion */
402
+ @media (prefers-reduced-motion: reduce) {
403
+ *, *::before, *::after {
404
+ animation-duration: 0.01ms !important;
405
+ animation-iteration-count: 1 !important;
406
+ transition-duration: 0.01ms !important;
407
+ }
408
+ }
409
+ </style>
410
+ </head>
411
+ <body>
412
+
413
+ <div class="stage-wrap">
414
+ <!-- HEADER -->
415
+ <header class="top">
416
+ <a class="brand" href="#">
417
+ <span class="brand-mark" aria-hidden="true">
418
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M12 2 3 7v10l9 5 9-5V7z"/></svg>
419
+ </span>
420
+ Qualia Framework · a project's journey
421
+ </a>
422
+ <span class="project-chip" aria-live="polite">
423
+ <span class="pulse" aria-hidden="true"></span>
424
+ <span id="projectName">sakani-app</span>
425
+ <span style="color:var(--ink-mute)">·</span>
426
+ <span id="milestoneName">Foundation</span>
427
+ </span>
428
+ </header>
429
+
430
+ <!-- STAGE -->
431
+ <section class="stage">
432
+ <!-- Narration + Track -->
433
+ <div class="narration">
434
+ <p class="eyebrow">The Road <span class="chapter" id="chapter">Chapter 1 / 8</span></p>
435
+ <h1 class="journey-title"><span id="titleMain">Kickoff</span> <span class="accent" id="titleSub">— research &amp; roadmap</span></h1>
436
+ <p id="narrationBody">A new client project begins with <code class="inline">/qualia-new</code>. Four researcher subagents spawn in parallel to study stack, features, architecture, and common pitfalls.</p>
437
+ <span class="step-badge live" id="stepBadge">
438
+ <span class="tag">step</span>
439
+ <span id="stepCmd">/qualia-new</span>
440
+ </span>
441
+
442
+ <!-- The main journey track (SVG) -->
443
+ <div class="track" aria-label="Journey animation">
444
+ <svg viewBox="0 0 1200 480" preserveAspectRatio="xMidYMid meet">
445
+ <defs>
446
+ <filter id="glow">
447
+ <feGaussianBlur stdDeviation="3.5" result="b"/>
448
+ <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
449
+ </filter>
450
+ <radialGradient id="tokenGrad">
451
+ <stop offset="0" stop-color="#5de3eb" stop-opacity="1"/>
452
+ <stop offset="0.6" stop-color="#00ced9" stop-opacity="0.9"/>
453
+ <stop offset="1" stop-color="#00ced9" stop-opacity="0"/>
454
+ </radialGradient>
455
+ </defs>
456
+
457
+ <!-- the winding road -->
458
+ <path id="journeyPath"
459
+ d="M 80 240
460
+ C 200 80, 320 80, 430 240
461
+ S 660 400, 770 240
462
+ S 1000 80, 1120 240"
463
+ fill="none"
464
+ stroke="#1c232c"
465
+ stroke-width="3"
466
+ stroke-linecap="round"/>
467
+
468
+ <!-- completed highlight -->
469
+ <path id="journeyDone"
470
+ d="M 80 240
471
+ C 200 80, 320 80, 430 240
472
+ S 660 400, 770 240
473
+ S 1000 80, 1120 240"
474
+ fill="none"
475
+ stroke="#00ced9"
476
+ stroke-width="3"
477
+ stroke-linecap="round"
478
+ stroke-dasharray="0 3000"
479
+ opacity="0.9"/>
480
+
481
+ <!-- stations placed via JS -->
482
+ <g id="stationsGroup"></g>
483
+
484
+ <!-- orbs spawned at current station -->
485
+ <g id="orbLayer"></g>
486
+
487
+ <!-- particles along the path -->
488
+ <g id="particleLayer"></g>
489
+
490
+ <!-- the PROJECT TOKEN that travels -->
491
+ <g id="projectToken" transform="translate(80 240)">
492
+ <circle r="28" fill="url(#tokenGrad)" opacity="0.5"/>
493
+ <circle r="14" fill="#05070a" stroke="#00ced9" stroke-width="1.5" filter="url(#glow)"/>
494
+ <text y="4" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="9" fill="#00ced9" font-weight="600">PRJ</text>
495
+ </g>
496
+ </svg>
497
+
498
+ <div class="track-footer">
499
+ <span class="progress-label">Progress</span>
500
+ <div class="progress-bar" aria-hidden="true"><div class="progress-fill" id="progressFill"></div></div>
501
+ <span class="progress-label" id="progressPct">0%</span>
502
+ <span class="controls">
503
+ <button class="btn" id="playBtn" aria-pressed="true">Pause</button>
504
+ <button class="btn" id="restartBtn">Restart</button>
505
+ </span>
506
+ </div>
507
+ </div>
508
+ </div>
509
+
510
+ <!-- Live terminal -->
511
+ <aside class="term" aria-label="Live project trace">
512
+ <div class="term-head">
513
+ <span class="term-dot r"></span>
514
+ <span class="term-dot y"></span>
515
+ <span class="term-dot g"></span>
516
+ <span class="term-title">~/sakani-app · main</span>
517
+ </div>
518
+ <div class="term-body" id="termBody" role="log" aria-live="polite"></div>
519
+ </aside>
520
+ </section>
521
+
522
+ <!-- BOTTOM STATS -->
523
+ <section class="bottom" aria-label="Live stats">
524
+ <div class="stat" id="stat-phase">
525
+ <span class="k">Current Phase</span>
526
+ <span class="v" id="phaseV">—</span>
527
+ <span class="hint" id="phaseH">waiting to start</span>
528
+ </div>
529
+ <div class="stat" id="stat-agents">
530
+ <span class="k">Agents Active</span>
531
+ <span class="v" id="agentsV">0</span>
532
+ <span class="hint" id="agentsH">no subagents running</span>
533
+ </div>
534
+ <div class="stat" id="stat-tasks">
535
+ <span class="k">Tasks Completed</span>
536
+ <span class="v" id="tasksV">0 <span style="color:var(--ink-mute); font-size: 0.7em;">/ 4</span></span>
537
+ <span class="hint" id="tasksH">phase not yet started</span>
538
+ </div>
539
+ <div class="stat" id="stat-gate">
540
+ <span class="k">Quality Gates</span>
541
+ <span class="v" id="gateV">—</span>
542
+ <span class="hint" id="gateH">pending</span>
543
+ </div>
544
+ </section>
545
+ </div>
546
+
547
+ <footer>
548
+ one project · one journey · fresh context per agent
549
+ </footer>
550
+
551
+ <script>
552
+ (() => {
553
+ 'use strict';
554
+
555
+ const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
556
+ const SVGNS = 'http://www.w3.org/2000/svg';
557
+
558
+ // ==========================================================
559
+ // Stations along the journey path (t = 0..1)
560
+ // ==========================================================
561
+ const stations = [
562
+ { id: 'new', t: 0.03, label: 'KICKOFF', cmd: '/qualia-new', title: 'Kickoff', sub: '— research & roadmap' },
563
+ { id: 'plan', t: 0.14, label: 'PLAN', cmd: '/qualia-plan', title: 'Plan', sub: '— phase plan + contracts' },
564
+ { id: 'build', t: 0.30, label: 'BUILD', cmd: '/qualia-build', title: 'Build', sub: '— wave-based parallel work' },
565
+ { id: 'verify', t: 0.46, label: 'VERIFY', cmd: '/qualia-verify', title: 'Verify', sub: '— goal-backward check' },
566
+ { id: 'milestone', t: 0.59, label: 'MILESTONE', cmd: '/qualia-milestone', title: 'Milestone', sub: '— close & open next' },
567
+ { id: 'polish', t: 0.72, label: 'POLISH', cmd: '/qualia-polish', title: 'Polish', sub: '— design & UX pass' },
568
+ { id: 'ship', t: 0.86, label: 'SHIP', cmd: '/qualia-ship', title: 'Ship', sub: '— gates + deploy + verify' },
569
+ { id: 'handoff', t: 0.98, label: 'HANDOFF', cmd: '/qualia-handoff', title: 'Handoff', sub: '— four deliverables' },
570
+ ];
571
+
572
+ // ==========================================================
573
+ // Narration per chapter
574
+ // ==========================================================
575
+ const chapters = {
576
+ new: 'A new client project begins with `/qualia-new`. Four researcher subagents spawn in parallel — they study stack, features, architecture, and common pitfalls — then synthesise a JOURNEY.md.',
577
+ plan: 'For each phase, `/qualia-plan` runs a planner agent with a plan-checker in a revision loop. Tasks get specificity, waves, and a verification contract.',
578
+ build: 'Builders spawn one-per-task, in waves. Independent tasks run at the same time. Each builder gets only its task + PROJECT.md — a fresh context, every time.',
579
+ verify: 'A fresh verifier agent checks the phase against its goal — not just whether tasks ran, but whether the thing actually works.',
580
+ milestone: 'The milestone closes at a human gate. Artifacts archive, requirements flip to complete, and the next milestone opens from JOURNEY.md.',
581
+ polish: 'The last milestone is Handoff. First: polish. One sweep across responsive, accessible, motion, copy, and edge-case states.',
582
+ ship: 'Quality gates run: tsc, lint, build, tests. If all pass, the project deploys to production — then a 5-check post-deploy verification.',
583
+ handoff: 'Four deliverables: production URL, client documentation, credentials & assets archive, ERP finalization. The project is done.',
584
+ };
585
+
586
+ // ==========================================================
587
+ // Terminal events per chapter
588
+ // ==========================================================
589
+ const termEvents = {
590
+ new: [
591
+ { t: 100, html: '<span class="t-prompt">›</span> <span class="t-cmd">/qualia-new</span> <span class="t-mute">--auto</span>' },
592
+ { t: 400, html: '<span class="t-mute">spawning 4 researchers in parallel…</span>' },
593
+ { t: 750, html: '<span class="t-mute"> ├─ stack · Context7</span>' },
594
+ { t: 900, html: '<span class="t-mute"> ├─ features · WebSearch</span>' },
595
+ { t: 1050, html: '<span class="t-mute"> ├─ architecture</span>' },
596
+ { t: 1200, html: '<span class="t-mute"> └─ pitfalls</span>' },
597
+ { t: 2400, html: '<span class="t-ok">✓</span> research synthesised → <span class="t-accent">SUMMARY.md</span>' },
598
+ { t: 2800, html: '<span class="t-violet">→</span> roadmapper · <span class="t-accent">JOURNEY.md</span> (5 milestones)' },
599
+ ],
600
+ plan: [
601
+ { t: 100, html: '<span class="t-prompt">›</span> <span class="t-cmd">/qualia-plan</span>' },
602
+ { t: 400, html: '<span class="t-mute">planner: drafting 4 tasks, 2 waves…</span>' },
603
+ { t: 1100, html: '<span class="t-mute">plan-checker: validating…</span>' },
604
+ { t: 1800, html: '<span class="t-warn">✗</span> <span class="t-mute">task 3 missing verification contract</span>' },
605
+ { t: 2200, html: '<span class="t-mute">revision 1/3 — planner revises task 3</span>' },
606
+ { t: 3000, html: '<span class="t-ok">✓</span> plan validated · PHASE_PLAN.md' },
607
+ ],
608
+ build: [
609
+ { t: 100, html: '<span class="t-prompt">›</span> <span class="t-cmd">/qualia-build</span>' },
610
+ { t: 400, html: '<span class="t-mute">wave 1 — 2 builders in parallel</span>' },
611
+ { t: 900, html: '<span class="t-mute"> ⧗ task 1: auth schema + RLS</span>' },
612
+ { t: 1050, html: '<span class="t-mute"> ⧗ task 2: login page + form</span>' },
613
+ { t: 2100, html: ' <span class="t-ok">✓</span> task 1 committed <span class="t-mute">a1b2c3d</span>' },
614
+ { t: 2300, html: ' <span class="t-ok">✓</span> task 2 committed <span class="t-mute">e4f5a6b</span>' },
615
+ { t: 2600, html: '<span class="t-mute">wave 2 — 2 builders in parallel</span>' },
616
+ { t: 3700, html: ' <span class="t-ok">✓</span> task 3 committed <span class="t-mute">7c8d9e0</span>' },
617
+ { t: 3900, html: ' <span class="t-ok">✓</span> task 4 committed <span class="t-mute">f1e2d3c</span>' },
618
+ ],
619
+ verify: [
620
+ { t: 100, html: '<span class="t-prompt">›</span> <span class="t-cmd">/qualia-verify</span>' },
621
+ { t: 500, html: '<span class="t-mute">verifier: loading success criteria…</span>' },
622
+ { t: 1100, html: ' <span class="t-ok">✓</span> sign-in happy path' },
623
+ { t: 1400, html: ' <span class="t-ok">✓</span> session persists across refresh' },
624
+ { t: 1700, html: ' <span class="t-ok">✓</span> RLS denies unauthenticated reads' },
625
+ { t: 2000, html: ' <span class="t-ok">✓</span> <span class="t-mute">npx tsc --noEmit clean</span>' },
626
+ { t: 2400, html: '<span class="t-ok">✓</span> goal met — phase verified' },
627
+ ],
628
+ milestone: [
629
+ { t: 100, html: '<span class="t-prompt">›</span> <span class="t-cmd">/qualia-milestone</span>' },
630
+ { t: 500, html: '<span class="t-mute">archiving artifacts of M1 · Foundation…</span>' },
631
+ { t: 1100, html: '<span class="t-ok">✓</span> requirements marked complete' },
632
+ { t: 1500, html: '<span class="t-violet">→</span> opening M2 · <span class="t-accent">Core Features</span>' },
633
+ ],
634
+ polish: [
635
+ { t: 100, html: '<span class="t-prompt">›</span> <span class="t-cmd">/qualia-polish</span>' },
636
+ { t: 500, html: '<span class="t-mute">scanning: responsive, a11y, motion, copy…</span>' },
637
+ { t: 1200, html: ' <span class="t-ok">✓</span> 44px touch targets' },
638
+ { t: 1500, html: ' <span class="t-ok">✓</span> reduced-motion honored' },
639
+ { t: 1800, html: ' <span class="t-ok">✓</span> empty / error / loading states' },
640
+ { t: 2200, html: '<span class="t-ok">✓</span> polish complete' },
641
+ ],
642
+ ship: [
643
+ { t: 100, html: '<span class="t-prompt">›</span> <span class="t-cmd">/qualia-ship</span>' },
644
+ { t: 500, html: '<span class="t-mute">gates:</span>' },
645
+ { t: 800, html: ' <span class="t-ok">✓</span> tsc &nbsp;<span class="t-ok">✓</span> lint' },
646
+ { t: 1050, html: ' <span class="t-ok">✓</span> build &nbsp;<span class="t-ok">✓</span> tests' },
647
+ { t: 1400, html: '<span class="t-mute">vercel --prod …</span>' },
648
+ { t: 2400, html: '<span class="t-ok">✓</span> live · <span class="t-accent">https://sakani.app</span>' },
649
+ { t: 2700, html: '<span class="t-ok">✓</span> 5-check post-deploy verification' },
650
+ ],
651
+ handoff: [
652
+ { t: 100, html: '<span class="t-prompt">›</span> <span class="t-cmd">/qualia-handoff</span>' },
653
+ { t: 500, html: ' <span class="t-ok">✓</span> production URL' },
654
+ { t: 800, html: ' <span class="t-ok">✓</span> client documentation' },
655
+ { t: 1100, html: ' <span class="t-ok">✓</span> credentials &amp; assets' },
656
+ { t: 1400, html: ' <span class="t-ok">✓</span> ERP finalization' },
657
+ { t: 1800, html: '<span class="t-ok">✓</span> <span class="t-accent">project delivered.</span>' },
658
+ ],
659
+ };
660
+
661
+ // ==========================================================
662
+ // Per-chapter visual actions (orbs, particles, stats)
663
+ // ==========================================================
664
+ const chapterVisuals = {
665
+ new: {
666
+ milestone: 'Foundation',
667
+ phase: 'Kickoff',
668
+ phaseHint: 'research running',
669
+ gate: 'n/a',
670
+ gateHint: 'pre-plan',
671
+ orbs: [
672
+ { name: 'stack', dx: -70, dy: -55 },
673
+ { name: 'features', dx: 70, dy: -55 },
674
+ { name: 'architecture', dx: -70, dy: 55 },
675
+ { name: 'pitfalls', dx: 70, dy: 55 },
676
+ ],
677
+ taskMax: 0, tasksDone: 0,
678
+ },
679
+ plan: {
680
+ milestone: 'Foundation',
681
+ phase: 'Phase 1 · Auth',
682
+ phaseHint: 'planning tasks',
683
+ gate: 'plan-checker',
684
+ gateHint: 'validating plan',
685
+ orbs: [
686
+ { name: 'planner', dx: -55, dy: 0 },
687
+ { name: 'plan-checker', dx: 55, dy: 0 },
688
+ ],
689
+ taskMax: 4, tasksDone: 0,
690
+ },
691
+ build: {
692
+ milestone: 'Foundation',
693
+ phase: 'Phase 1 · Auth',
694
+ phaseHint: 'building in waves',
695
+ gate: 'frontend-guard',
696
+ gateHint: 'reading DESIGN.md',
697
+ orbs: [
698
+ { name: 'build-1', dx: -90, dy: -45 },
699
+ { name: 'build-2', dx: -30, dy: -45 },
700
+ { name: 'build-3', dx: 30, dy: -45 },
701
+ { name: 'build-4', dx: 90, dy: -45 },
702
+ ],
703
+ taskMax: 4, tasksDone: 4, taskReveal: true,
704
+ },
705
+ verify: {
706
+ milestone: 'Foundation',
707
+ phase: 'Phase 1 · Auth',
708
+ phaseHint: 'goal-backward check',
709
+ gate: 'verification',
710
+ gateHint: 'spawning QA browser',
711
+ orbs: [
712
+ { name: 'verifier', dx: -55, dy: 0 },
713
+ { name: 'qa-browser', dx: 55, dy: 0 },
714
+ ],
715
+ taskMax: 4, tasksDone: 4,
716
+ },
717
+ milestone: {
718
+ milestone: 'Core Features',
719
+ phase: 'Milestone boundary',
720
+ phaseHint: 'human gate — approved',
721
+ gate: 'gate passed',
722
+ gateHint: 'M1 → M2',
723
+ orbs: [],
724
+ taskMax: 0, tasksDone: 0,
725
+ },
726
+ polish: {
727
+ milestone: 'Handoff',
728
+ phase: 'Phase 1 · Polish',
729
+ phaseHint: 'design & UX sweep',
730
+ gate: 'design-guard',
731
+ gateHint: 'responsive + a11y',
732
+ orbs: [
733
+ { name: 'polish-1', dx: -40, dy: 0 },
734
+ { name: 'polish-2', dx: 40, dy: 0 },
735
+ ],
736
+ taskMax: 3, tasksDone: 3,
737
+ },
738
+ ship: {
739
+ milestone: 'Handoff',
740
+ phase: 'Phase 3 · Ship',
741
+ phaseHint: 'deploying to prod',
742
+ gate: 'deploy-guard',
743
+ gateHint: 'tsc · lint · build · tests',
744
+ orbs: [
745
+ { name: 'gates', dx: -55, dy: 0 },
746
+ { name: 'deploy', dx: 55, dy: 0 },
747
+ ],
748
+ taskMax: 5, tasksDone: 5,
749
+ },
750
+ handoff: {
751
+ milestone: 'Delivered',
752
+ phase: 'Complete',
753
+ phaseHint: 'project delivered',
754
+ gate: 'all passed',
755
+ gateHint: '4/4 deliverables',
756
+ orbs: [],
757
+ taskMax: 4, tasksDone: 4,
758
+ },
759
+ };
760
+
761
+ // ==========================================================
762
+ // DOM + SVG setup
763
+ // ==========================================================
764
+ const journeyPath = document.getElementById('journeyPath');
765
+ const journeyDone = document.getElementById('journeyDone');
766
+ const totalLen = journeyPath.getTotalLength();
767
+ journeyDone.style.transition = 'stroke-dasharray 1200ms ' + 'cubic-bezier(0.4, 0, 0.2, 1)';
768
+
769
+ const stationsGroup = document.getElementById('stationsGroup');
770
+ stations.forEach((s, i) => {
771
+ const pt = journeyPath.getPointAtLength(totalLen * s.t);
772
+ const g = document.createElementNS(SVGNS, 'g');
773
+ g.setAttribute('class', 'station');
774
+ g.setAttribute('id', 'st-' + s.id);
775
+ g.setAttribute('transform', `translate(${pt.x} ${pt.y})`);
776
+ g.innerHTML = `
777
+ <circle class="ring" r="18"/>
778
+ <circle class="core" r="8"/>
779
+ <text class="label" y="-28">${s.label}</text>
780
+ <text class="cmd" y="38">${s.cmd}</text>
781
+ `;
782
+ stationsGroup.appendChild(g);
783
+ s._pt = pt;
784
+ });
785
+
786
+ const projectToken = document.getElementById('projectToken');
787
+ const orbLayer = document.getElementById('orbLayer');
788
+ const particleLayer = document.getElementById('particleLayer');
789
+
790
+ // ==========================================================
791
+ // Movers / helpers
792
+ // ==========================================================
793
+ let tokenAnim = null;
794
+ function moveTokenTo(x, y, dur) {
795
+ if (tokenAnim) cancelAnimationFrame(tokenAnim);
796
+ const startTransform = projectToken.getAttribute('transform') || 'translate(80 240)';
797
+ const m = startTransform.match(/translate\(([-\d.]+)\s+([-\d.]+)\)/);
798
+ const sx = m ? parseFloat(m[1]) : 80;
799
+ const sy = m ? parseFloat(m[2]) : 240;
800
+ const start = performance.now();
801
+ const d = prefersReduced ? 0 : dur;
802
+ function tick(now) {
803
+ const k = Math.min(1, (now - start) / Math.max(d, 1));
804
+ const e = 1 - Math.pow(1 - k, 3); // easeOutCubic
805
+ const x1 = sx + (x - sx) * e;
806
+ const y1 = sy + (y - sy) * e;
807
+ projectToken.setAttribute('transform', `translate(${x1} ${y1})`);
808
+ if (k < 1) tokenAnim = requestAnimationFrame(tick);
809
+ }
810
+ tokenAnim = requestAnimationFrame(tick);
811
+ }
812
+
813
+ function clearOrbs() {
814
+ [...orbLayer.children].forEach(c => {
815
+ c.classList.add('orb-fade-out');
816
+ setTimeout(() => c.remove(), 400);
817
+ });
818
+ }
819
+ function spawnOrbs(cx, cy, defs) {
820
+ defs.forEach((o, i) => {
821
+ setTimeout(() => {
822
+ const g = document.createElementNS(SVGNS, 'g');
823
+ g.setAttribute('class', 'orb orb-fade-in');
824
+ g.setAttribute('transform', `translate(${cx + o.dx} ${cy + o.dy})`);
825
+ g.innerHTML = `
826
+ <circle class="body" r="14"/>
827
+ <text class="lbl" y="3">${o.name}</text>
828
+ `;
829
+ orbLayer.appendChild(g);
830
+ }, i * (prefersReduced ? 0 : 130));
831
+ });
832
+ }
833
+
834
+ function spawnParticles(fromX, fromY, count) {
835
+ if (prefersReduced) return;
836
+ for (let i = 0; i < count; i++) {
837
+ setTimeout(() => {
838
+ const c = document.createElementNS(SVGNS, 'circle');
839
+ c.setAttribute('r', 2.5);
840
+ c.setAttribute('cx', fromX);
841
+ c.setAttribute('cy', fromY);
842
+ c.setAttribute('fill', '#00ced9');
843
+ c.setAttribute('filter', 'url(#glow)');
844
+ particleLayer.appendChild(c);
845
+ const ang = Math.random() * Math.PI * 2;
846
+ const dist = 40 + Math.random() * 70;
847
+ const start = performance.now();
848
+ const dur = 900 + Math.random() * 500;
849
+ function tick(now) {
850
+ const k = (now - start) / dur;
851
+ if (k >= 1) { c.remove(); return; }
852
+ const e = 1 - Math.pow(1 - k, 2);
853
+ c.setAttribute('cx', fromX + Math.cos(ang) * dist * e);
854
+ c.setAttribute('cy', fromY + Math.sin(ang) * dist * e);
855
+ c.setAttribute('opacity', 1 - k);
856
+ requestAnimationFrame(tick);
857
+ }
858
+ requestAnimationFrame(tick);
859
+ }, i * 60);
860
+ }
861
+ }
862
+
863
+ function updateHighlight(t) {
864
+ const len = totalLen * t;
865
+ journeyDone.setAttribute('stroke-dasharray', `${len} ${totalLen}`);
866
+ }
867
+
868
+ // ==========================================================
869
+ // Terminal
870
+ // ==========================================================
871
+ const termBody = document.getElementById('termBody');
872
+ let termTimers = [];
873
+ function termClear() {
874
+ termTimers.forEach(clearTimeout); termTimers = [];
875
+ termBody.innerHTML = '';
876
+ }
877
+ function termPush(html) {
878
+ const s = document.createElement('span');
879
+ s.className = 'term-line';
880
+ s.innerHTML = html;
881
+ termBody.appendChild(s);
882
+ requestAnimationFrame(() => s.classList.add('show'));
883
+ while (termBody.children.length > 11) termBody.firstChild.remove();
884
+ }
885
+ function termPlay(events) {
886
+ termClear();
887
+ events.forEach(e => {
888
+ termTimers.push(setTimeout(() => termPush(e.html), prefersReduced ? 0 : e.t));
889
+ });
890
+ }
891
+
892
+ // ==========================================================
893
+ // Stats panel
894
+ // ==========================================================
895
+ function setStats(v) {
896
+ document.getElementById('phaseV').textContent = v.phase;
897
+ document.getElementById('phaseH').textContent = v.phaseHint;
898
+ document.getElementById('agentsV').textContent = v.orbs.length;
899
+ document.getElementById('agentsH').textContent = v.orbs.length ? v.orbs.map(o => o.name).slice(0, 3).join(' · ') + (v.orbs.length > 3 ? ` +${v.orbs.length - 3}` : '') : 'no subagents running';
900
+ document.getElementById('tasksV').innerHTML = `${v.tasksDone} <span style="color:var(--ink-mute); font-size: 0.7em;">/ ${v.taskMax || '—'}</span>`;
901
+ document.getElementById('tasksH').textContent = v.taskMax ? (v.tasksDone === v.taskMax ? 'phase complete' : 'in progress') : 'no tasks yet';
902
+ document.getElementById('gateV').textContent = v.gate;
903
+ document.getElementById('gateH').textContent = v.gateHint;
904
+
905
+ [...document.querySelectorAll('.stat')].forEach(s => s.classList.remove('live'));
906
+ if (v.orbs.length) document.getElementById('stat-agents').classList.add('live');
907
+ if (v.taskMax && v.tasksDone < v.taskMax) document.getElementById('stat-tasks').classList.add('live');
908
+ document.getElementById('stat-phase').classList.add('live');
909
+ }
910
+
911
+ // ==========================================================
912
+ // Main journey loop
913
+ // ==========================================================
914
+ let currentIdx = 0;
915
+ let playing = true;
916
+ let chapterTimer = null;
917
+
918
+ function goToChapter(i) {
919
+ currentIdx = i;
920
+ const s = stations[i];
921
+ const v = chapterVisuals[s.id];
922
+
923
+ // station states
924
+ document.querySelectorAll('.station').forEach((el, idx) => {
925
+ el.classList.remove('active', 'passed');
926
+ if (idx < i) el.classList.add('passed');
927
+ if (idx === i) el.classList.add('active');
928
+ });
929
+
930
+ // highlight + token
931
+ updateHighlight(s.t);
932
+ moveTokenTo(s._pt.x, s._pt.y, 1200);
933
+
934
+ // narration
935
+ document.getElementById('chapter').textContent = `Chapter ${i + 1} / ${stations.length}`;
936
+ document.getElementById('titleMain').textContent = s.title;
937
+ document.getElementById('titleSub').textContent = s.sub;
938
+ document.getElementById('narrationBody').innerHTML = chapters[s.id].replace(/`([^`]+)`/g, '<code class="inline">$1</code>');
939
+ document.getElementById('stepCmd').textContent = s.cmd;
940
+
941
+ // project chip milestone
942
+ document.getElementById('milestoneName').textContent = v.milestone;
943
+
944
+ // progress bar
945
+ const pct = Math.round(((i + 1) / stations.length) * 100);
946
+ document.getElementById('progressFill').style.width = pct + '%';
947
+ document.getElementById('progressPct').textContent = pct + '%';
948
+
949
+ // orbs + particles (after token arrives)
950
+ setTimeout(() => {
951
+ clearOrbs();
952
+ setTimeout(() => {
953
+ if (v.orbs.length) spawnOrbs(s._pt.x, s._pt.y, v.orbs);
954
+ spawnParticles(s._pt.x, s._pt.y, v.orbs.length ? 6 : 3);
955
+ }, 300);
956
+ }, prefersReduced ? 0 : 900);
957
+
958
+ // stats
959
+ setStats(v);
960
+
961
+ // terminal
962
+ termPlay(termEvents[s.id] || []);
963
+ }
964
+
965
+ function nextChapter() {
966
+ goToChapter((currentIdx + 1) % stations.length);
967
+ }
968
+
969
+ function startLoop() {
970
+ stopLoop();
971
+ // duration per chapter
972
+ chapterTimer = setInterval(nextChapter, 5200);
973
+ playing = true;
974
+ const btn = document.getElementById('playBtn');
975
+ btn.setAttribute('aria-pressed', 'true');
976
+ btn.textContent = 'Pause';
977
+ }
978
+ function stopLoop() {
979
+ if (chapterTimer) clearInterval(chapterTimer);
980
+ chapterTimer = null;
981
+ playing = false;
982
+ const btn = document.getElementById('playBtn');
983
+ btn.setAttribute('aria-pressed', 'false');
984
+ btn.textContent = 'Play';
985
+ }
986
+
987
+ document.getElementById('playBtn').addEventListener('click', () => {
988
+ if (playing) stopLoop(); else startLoop();
989
+ });
990
+ document.getElementById('restartBtn').addEventListener('click', () => {
991
+ stopLoop();
992
+ goToChapter(0);
993
+ startLoop();
994
+ });
995
+
996
+ // kick it off
997
+ goToChapter(0);
998
+ if (prefersReduced) {
999
+ // play once to the end, no auto-loop
1000
+ stopLoop();
1001
+ } else {
1002
+ startLoop();
1003
+ }
1004
+
1005
+ })();
1006
+ </script>
1007
+ </body>
1008
+ </html>