decklight 0.1.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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/SPEC.md +372 -0
  4. package/cli/bundle.mjs +388 -0
  5. package/cli/decklight.mjs +95 -0
  6. package/cli/edit.mjs +142 -0
  7. package/cli/init.mjs +215 -0
  8. package/cli/rec.mjs +538 -0
  9. package/dist/decklight.css +1068 -0
  10. package/dist/decklight.js +239 -0
  11. package/dist/decklight.js.map +7 -0
  12. package/docs/architecture.svg +94 -0
  13. package/docs/demo.svg +124 -0
  14. package/package.json +54 -0
  15. package/themes/README.md +61 -0
  16. package/themes/aliens.css +84 -0
  17. package/themes/apple2.css +126 -0
  18. package/themes/aurora.css +84 -0
  19. package/themes/berry.css +80 -0
  20. package/themes/blade-runner.css +83 -0
  21. package/themes/c64.css +85 -0
  22. package/themes/carbon.css +82 -0
  23. package/themes/citrus.css +80 -0
  24. package/themes/coastal.css +80 -0
  25. package/themes/cosmos.css +86 -0
  26. package/themes/dune.css +80 -0
  27. package/themes/eclipse.css +82 -0
  28. package/themes/ember.css +80 -0
  29. package/themes/fjord.css +80 -0
  30. package/themes/friends.css +82 -0
  31. package/themes/gallery.html +203 -0
  32. package/themes/gameboy.css +118 -0
  33. package/themes/genesis.css +84 -0
  34. package/themes/glacier.css +82 -0
  35. package/themes/godfather.css +82 -0
  36. package/themes/graphite.css +80 -0
  37. package/themes/harvest.css +80 -0
  38. package/themes/ibm-modern.css +83 -0
  39. package/themes/ibm-oldschool.css +84 -0
  40. package/themes/ink.css +82 -0
  41. package/themes/latte.css +80 -0
  42. package/themes/linen.css +80 -0
  43. package/themes/meadow.css +80 -0
  44. package/themes/metropolis.css +85 -0
  45. package/themes/miami-vice.css +84 -0
  46. package/themes/midnight.css +80 -0
  47. package/themes/mint.css +82 -0
  48. package/themes/moss.css +82 -0
  49. package/themes/obsidian.css +82 -0
  50. package/themes/orchid.css +82 -0
  51. package/themes/packs.json +89 -0
  52. package/themes/paper.css +83 -0
  53. package/themes/peony.css +84 -0
  54. package/themes/porcelain.css +82 -0
  55. package/themes/pulp-fiction.css +84 -0
  56. package/themes/reveal-beige.css +87 -0
  57. package/themes/reveal-black.css +83 -0
  58. package/themes/reveal-blood.css +84 -0
  59. package/themes/reveal-dracula.css +83 -0
  60. package/themes/reveal-league.css +88 -0
  61. package/themes/reveal-moon.css +83 -0
  62. package/themes/reveal-night.css +86 -0
  63. package/themes/reveal-serif.css +82 -0
  64. package/themes/reveal-simple.css +84 -0
  65. package/themes/reveal-sky.css +85 -0
  66. package/themes/reveal-solarized.css +83 -0
  67. package/themes/reveal-white.css +82 -0
  68. package/themes/sepia.css +81 -0
  69. package/themes/seriph.css +82 -0
  70. package/themes/severance.css +82 -0
  71. package/themes/slate.css +80 -0
  72. package/themes/snes.css +82 -0
  73. package/themes/star-wars.css +85 -0
  74. package/themes/storm.css +80 -0
  75. package/themes/stranger-things.css +84 -0
  76. package/themes/synthwave.css +90 -0
  77. package/themes/terminator.css +84 -0
  78. package/themes/velvet.css +80 -0
  79. package/tools/gemini-tts.mjs +140 -0
  80. package/tools/publish-site-voices.mjs +71 -0
  81. package/tools/voiceover-server.mjs +0 -0
  82. package/tools/voiceover.mjs +155 -0
package/SPEC.md ADDED
@@ -0,0 +1,372 @@
1
+ # Decklight — Specification v1
2
+
3
+ A presentation library in the Reveal.js tradition, designed to be **authored by AI agents and humans alike**: a deck is a single HTML file, the runtime is one JS + one CSS + one theme CSS, no build step. Every feature is designed to be **verifiable by a headless render** (the authoring agent can prove a deck works).
4
+
5
+ This document is the contract. All subsystems (core, themes, terminal, demos) build against it.
6
+
7
+ ---
8
+
9
+ ## 1. Deck anatomy
10
+
11
+ ```html
12
+ <!doctype html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="utf-8">
16
+ <link rel="stylesheet" href="decklight/dist/decklight.css">
17
+ <link rel="stylesheet" href="decklight/themes/aurora.css">
18
+ </head>
19
+ <body>
20
+ <div class="decklight">
21
+ <section>
22
+ <h2>A plain HTML slide</h2>
23
+ <ul data-build>
24
+ <li>First point</li>
25
+ <li>Second — with <strong>bold</strong> and <code>code</code></li>
26
+ </ul>
27
+ <aside class="notes">Speaker notes. ⟨CLICK⟩ markers align with builds.</aside>
28
+ </section>
29
+
30
+ <section data-markdown>
31
+ <script type="text/template">
32
+ ## A markdown slide
33
+
34
+ Content here. HTML is the default; markdown is opt-in per slide.
35
+
36
+ Note:
37
+ Speaker notes in markdown slides.
38
+ </script>
39
+ </section>
40
+ </div>
41
+ <script src="decklight/dist/decklight.js"></script>
42
+ <script>Decklight.init({ transition: 'fade' });</script>
43
+ </body>
44
+ </html>
45
+ ```
46
+
47
+ - Slides are `<section>` children of `.decklight`. Flat list (no vertical nesting in v1).
48
+ - Markdown slides: `data-markdown` on the section, content in `<script type="text/template">`
49
+ (never `<textarea>` — avoids the escaping and lazy-continuation bugs we hit in Reveal).
50
+ Markdown is CommonMark via bundled `marked`. `Note:` starts speaker notes.
51
+ - Speaker notes: `<aside class="notes">` (HTML) or `Note:` (markdown).
52
+ - **Rehearse notes** (optional, build-time authored): a condensed cue-card variant of the notes for the speaker view's rehearse mode (§8) — a few words per segment instead of full prose, with **exactly the same ⟨CLICK⟩ segmentation** as the notes so build-step highlighting aligns. HTML: `<aside class="rehearse">` as a sibling of the notes aside. Markdown: a line `Rehearse:` after the `Note:` prose starts the cue block. Slides without a rehearse aside fall back to the full notes in rehearse mode.
53
+ - **Subtitle**: the `<p>` immediately following a slide's leading `h1`/`h2` is auto-marked `.subtitle` and gets one canonical look (muted, 0.72em) whether the slide is markdown- or HTML-authored. Opt out per slide with `data-subtitle="none"` on the section; an author-placed `class="subtitle"` is respected as-is. Don't bake subtitle text into diagram SVGs — author it as this `<p>` so it themes and scales with the deck.
54
+
55
+ ## 2. Builds (Keynote-style; Reveal calls these fragments)
56
+
57
+ Design goal: **the container opts in, the engine does the rest** — one attribute on the
58
+ container, zero classes on the items.
59
+
60
+ ### 2.1 Authoring
61
+
62
+ | Syntax | Meaning |
63
+ |---|---|
64
+ | `data-build` on a container (`ul`, `ol`, `table`, `svg`, `g`, `div.columns`, …) | each **direct child** (li / tbody tr / g or shape / column) becomes one build step, in DOM order |
65
+ | `data-build` on a leaf element (`p`, `img`, `blockquote`, `pre`, one `g`) | the element itself is one build step |
66
+ | `data-build="fade-up"` | entrance style (see 2.3) |
67
+ | `data-build-order="3"` | explicit step index within the slide (default: document order) |
68
+ | `data-build-stay` on a child of a `data-build` container | child is exempt (stays static) |
69
+ | Markdown: `::: build` … `:::` container directive | wraps content in a `<div data-build>` |
70
+
71
+ ### 2.2 Engine semantics
72
+
73
+ - All build steps on a slide form one ordered sequence (document order, overridden by `data-build-order`; ties advance together).
74
+ - Navigation: `→`/`Space`/click advances one step; `←` reverses; arriving from the previous slide shows step 0 (nothing built); arriving *backwards* from the next slide shows all steps built.
75
+ - Hidden steps: `visibility: hidden` (not `display:none` — layout must not shift).
76
+ - Events: `decklight:build` fires with `{slide, index, total, direction}`.
77
+ - URL: `#/<slide>/<step>` deep-links to a build state.
78
+
79
+ ### 2.3 Entrance styles
80
+
81
+ `fade` (default) · `fade-up` · `fade-down` · `zoom` · `pop` (overshoot) · `draw` (SVG paths/lines: stroke-dashoffset animation; non-stroke elements fall back to fade) · `highlight` (element already visible; step emphasizes it: accent outline + others dim) · `none` (instant).
82
+
83
+ All entrance styles are CSS-driven (`.build-step[data-build-state="done|current|pending"]`), duration via `--build-duration` (theme-overridable, default 300ms).
84
+
85
+ ### 2.4 Build Provider API (for subsystems)
86
+
87
+ Complex widgets (code stepping, terminal player) register **build providers** instead of DOM steps:
88
+
89
+ ```js
90
+ Decklight.registerBuildProvider(element, {
91
+ count: 4, // number of steps this widget contributes
92
+ apply(i) { ... }, // called with current step index (0 = nothing, count = all)
93
+ label(i) { return 'git status' } // optional, for the speaker view step list
94
+ });
95
+ ```
96
+
97
+ The engine interleaves provider steps into the slide's sequence at the element's document position. Providers must be idempotent (`apply` may be called with any index in any order — e.g. deep links).
98
+
99
+ ## 3. SVG diagrams (first-class)
100
+
101
+ - Inline SVG is the canonical diagram format. `data-build` on `<svg>` or a `<g>` makes direct-child groups progressive (exactly the pattern from §2.1).
102
+ - **Theme-aware diagrams**: themes define diagram tokens (§5). Diagrams authored with
103
+ `var(--d-stroke)`, `var(--d-fill-1)`…`var(--d-fill-6)`, `var(--d-text)`, `var(--d-muted)`,
104
+ `var(--d-accent)` re-color automatically across all 30 themes.
105
+ Hardcoded-color SVGs still work; they just don't adapt.
106
+ - `data-build="draw"` on groups animates strokes (paths, lines, polylines) via dash-offset.
107
+ - The runtime namespaces `id` attributes inside each inline `<svg>` at init (prefix `svg{n}-`, rewriting `url(#…)` and `href="#…"` refs) — the defs-collision bug class is eliminated at the engine level.
108
+ - **Concept colors**: `data-concept="agent"` on a shape (or a group — its direct-child shapes recolor; text never does) pins that concept to ONE diagram-fill slot deck-wide, so a recurring concept never changes color between diagrams. Resolution: `init({ concepts: { agent: 3 } })` pins a slot (1–6) or any raw CSS color (`'var(--d-accent)'`); unconfigured names fall back to a stable hash of the name, identical across sessions and decks. The indirection targets a slot (`var(--d-fill-N)`), not a color, so concept identity survives all themes, generated ones included. Two concepts hashing to the same slot get a console warning telling the author to pin one explicitly. Applied on `sync()` (idempotent, covers dynamic slides).
109
+
110
+ ## 4. Motion
111
+
112
+ ### 4.1 Slide transitions
113
+ `transition: 'none' | 'fade' | 'slide' | 'scale' | 'flip'` — deck-level config, per-slide override `data-transition`. Duration `--transition-duration` (default 350ms). Reduced-motion: all transitions collapse to `none` under `prefers-reduced-motion`.
114
+
115
+ ### 4.2 Auto-animate (Magic Move)
116
+ - `data-auto-animate` on two adjacent sections.
117
+ - Matching: elements sharing `data-id`; unmatched elements fade in/out.
118
+ - Animated properties: position/size (FLIP transform), opacity, color, background, border-radius, font-size. Works for HTML and inline-SVG elements (x/y/width/height via transform).
119
+ - Duration `--auto-animate-duration` (default 500ms).
120
+
121
+ ### 4.3 Element animations
122
+ `data-animate="pulse | float | shake | spin | blink | bounce | swing | glow | breathe"` — looping attention animations, start when the slide becomes active (and only then; pause on inactive slides). Respect reduced-motion.
123
+
124
+ ## 5. Theming — the token contract
125
+
126
+ `decklight.css` = structure only (layout, builds, navigation chrome, print). A theme = one CSS file defining tokens on `.decklight`:
127
+
128
+ ```css
129
+ .decklight.theme-aurora { /* class added by the theme file itself via :root scoping */ }
130
+ ```
131
+
132
+ Themes set (all required):
133
+
134
+ | Group | Tokens |
135
+ |---|---|
136
+ | Canvas | `--bg` (canvas: color **or** gradient), `--bg-accent` (gradient values paint as an overlay above `--bg`; color values are ignored on the canvas and serve as a secondary-surface token), `--fg`, `--muted` |
137
+ | Type | `--font-body`, `--font-heading`, `--font-mono`, `--heading-color`, `--heading-weight`, `--link` |
138
+ | Accent | `--accent`, `--accent-contrast` (text on accent) |
139
+ | Blocks | `--block-bg`, `--block-border`, `--block-radius`, `--shadow` |
140
+ | Code | `--code-bg`, `--code-fg`, plus highlight token colors `--hl-keyword`, `--hl-string`, `--hl-number`, `--hl-comment`, `--hl-function`, `--hl-type`, `--hl-punct` |
141
+ | Diagram | `--d-stroke`, `--d-text`, `--d-muted`, `--d-accent`, `--d-fill-1` … `--d-fill-6` |
142
+ | Terminal | `--term-bg`, `--term-fg`, `--term-prompt`, `--term-cursor`, `--term-selection`, ANSI 16: `--ansi-black` … `--ansi-bright-white` |
143
+ | Builds | optionally override `--build-duration`, dim level `--dim-opacity` |
144
+
145
+ Requirements for the shipped themes:
146
+ - Body text ≥ WCAG AA contrast on `--bg`; code tokens ≥ AA on `--code-bg` (validated by `test/contrast.mjs`).
147
+ - **Diagram ink clears the diagram panels**: `--d-text` ≥ 3.0 and `--d-muted`/`--d-accent` ≥ 2.6 against **every** `--d-fill-1..6`, not just the canvas — labels sit on the fills, and a theme can pass every canvas gate while its boxes are unreadable (the gameboy lesson). Enforced by `test/contrast.mjs` on shipped themes and by the generator's property tests.
148
+ - Variety: ≥10 dark, ≥10 light, ≥4 serif-headed, ≥4 gradient/duotone canvases, ≤2 that are safe-corporate-boring.
149
+ - Fonts: system stacks or bundled-safe Google-font `@import` (deck authors may be offline: every theme must degrade to a system stack gracefully).
150
+
151
+ Runtime-**generated** themes (§8, `⌃T`) satisfy the same contract: `src/core/themegen.js` derives every token with WCAG luminance math and iterates until all of `test/contrast.mjs`'s gates pass — a generated theme can never fail validation (property-tested across seeds in `test/themegen.test.mjs`).
152
+
153
+ Generation also follows **codified palette rules** (R1–R8 in `themegen.js`), distilled from the most-loved editor themes (Solarized, Nord, Catppuccin, Gruvbox) and the 60-30-10 doctrine — each enforced by an independent property test:
154
+ - **R1 limited palette** — one base hue plus the harmony's ≤5 accent hues, reused across every role (syntax, links, diagram fills); never a fresh hue per token.
155
+ - **R2 quiet dominant areas** — the canvas stays near-neutral; chroma belongs to small accents, not large surfaces.
156
+ - **R3 dimmed pastels** — vivid rolls are biased toward muted and saturation is hard-capped below neon.
157
+ - **R4 one accent lightness band** — accent-family colors share a starting lightness and saturation, so no color shouts louder than its peers.
158
+ - **R5 selective contrast** — syntax roles differ by hue at similar brightness, not by brightness spikes.
159
+ - **R6 no pure black or white** — every neutral carries the base-hue tint.
160
+ - **R7 gradients sparingly** — ~15% of rolls, low-drift same-family washes only.
161
+ - **R8 semantic anchors** — terminal red/green/yellow keep their recognizable hue even when muted (the green band admits olive — Solarized's green is h68, Gruvbox's h63).
162
+
163
+ The **shipped themes conform to the same rules**, graded by `test/palette-rules.mjs` (part of `npm run verify`; R7 is graded on the collection — gradient canvases ≤ 30% of the set). A theme may opt out of a rule where conformance would break its identity — official brand colors, an intentional duotone canvas — by declaring the exception *in the theme file* with a reason: `rule-exception: R2 official Polar→Glow brand gradient canvas is the identity`. Undeclared violations fail the grader; declared ones are printed with every run so they stay reviewable.
164
+
165
+ ## 6. Code
166
+
167
+ - `<pre><code class="language-sql">…</code></pre>` — highlighting via bundled highlight.js
168
+ (languages: sql, js, ts, python, bash/shell, yaml, json, java, go, rust, html/xml, css, plaintext), themed through the `--hl-*` tokens (no separate hljs theme files).
169
+ - **Line stepping**: `data-lines="1|3-5|all"` on the `<pre>` → registers a build provider with one step per segment; non-highlighted lines get `--dim-opacity`. `data-lines-numbers` shows line numbers.
170
+ - Escaping rule for authors: use `&lt;` inside code blocks in HTML slides; markdown fences handle escaping automatically.
171
+
172
+ ## 7. Terminal recordings
173
+
174
+ ### 7.1 Recorder CLI (authoring-time; the only part with native deps)
175
+
176
+ `cli/rec.mjs`, invoked as `npx decklight rec <script.term.yaml> [-o out.cast.json]`
177
+ (the `decklight` dispatcher in `cli/decklight.mjs` also provides `refresh`, `export`, `bundle`,
178
+ and a global `--help`; the per-file entry points still run directly, undocumented).
179
+
180
+ Script format:
181
+ ```yaml
182
+ shell: zsh # default: $SHELL
183
+ cwd: ~/demo # default: script's directory
184
+ cols: 100 # default 100
185
+ rows: 28 # default 28
186
+ env: { NO_COLOR: "" } # extra env (merged over inherited)
187
+ prompt: "$ " # cosmetic prompt used in playback
188
+ redact: # regexes replaced with ▓▓▓ in captured output
189
+ - "sk-[A-Za-z0-9-_]+"
190
+ max_idle: 2.0 # clamp recorded pauses to this many seconds (default 2.0)
191
+ steps:
192
+ - cmd: export STAGE=demo
193
+ hide: true # runs in the session; omitted from playback (recorded, flagged hidden)
194
+ - cmd: git status
195
+ - sleep: 1.5 # pure pause: really sleeps at capture; timing marker in play/export
196
+ - cmd: npx wrangler deploy
197
+ timeout: 120 # seconds, default 60
198
+ wait_for: "Deployed" # step fails unless output matches before completion
199
+ type_speed: 2 # playback typing-speed multiplier for this step
200
+ note: deploys the worker # shows in speaker view step list
201
+ - cmd: myapp login
202
+ interact: # expect/send for interactive prompts
203
+ - expect: "Email: "
204
+ send: "demo@example.com\n"
205
+ - expect: "Password: "
206
+ send: { secret: "$APP_PASSWORD\n" } # sent for real; recorded as ▓▓▓
207
+ ```
208
+
209
+ Behavior: spawn PTY (node-pty), run each `cmd` sequentially in the same shell session,
210
+ capture raw output chunks with timestamps, apply redaction and idle-clamping, write the
211
+ cast. Commands run inside a brace group with the sentinel on the closing line, so a
212
+ command that reads stdin sees only `interact` sends (never the recorder's own control
213
+ lines). Exit non-zero if any step exceeds its timeout, an `expect` never matches
214
+ (including command exit before it fires), or the shell dies; `--allow-fail` records
215
+ failures as content (prompts and error output are often the point).
216
+
217
+ **Secrets**: a secret send is written to the PTY for real but stored as `▓▓▓` in the
218
+ cast's `input` record, and its value is auto-added to that step's output redaction.
219
+ Prefer the `$ENV` form — it resolves from the recorder's environment at run time, so
220
+ `--refresh` keeps working. Literal secret values (and script-`env` entries a `$NAME`
221
+ secret refers to) are scrubbed to `▓▓▓` in the embedded script; refreshing such a cast
222
+ requires the operator to provide the value via the environment.
223
+
224
+ **Refresh**: the cast embeds the full script. `decklight refresh <dir|cast…>` re-executes
225
+ every embedded script and rewrites casts whose output changed; prints a drift summary.
226
+ (Note: a cast that records the state of the repository it lives in — e.g. `git status` —
227
+ converges over two refreshes, since the rewrite itself changes that state.)
228
+
229
+ ### 7.2 Cast format (`.cast.json`, version 1)
230
+
231
+ ```json
232
+ {
233
+ "decklightCast": 1,
234
+ "meta": { "shell": "zsh", "cols": 100, "rows": 28, "recorded": "ISO-8601", "prompt": "$ " },
235
+ "script": { /* the source YAML as JSON (secrets scrubbed — see §7.1) */ },
236
+ "steps": [
237
+ { "cmd": "git status",
238
+ "output": [[0.031, "chunk…"], [0.480, "chunk…"]],
239
+ "exit": 0, "duration": 1.24, "note": "optional",
240
+ "hidden": true, /* optional: hide: true steps */
241
+ "typeSpeed": 2, /* optional: playback typing multiplier */
242
+ "input": [[0.5, "y\n"], [1.2, "▓▓▓"]] /* optional: interactive sends */ },
243
+ { "sleep": 1.5 } /* pure pause marker */
244
+ ]
245
+ }
246
+ ```
247
+
248
+ The format stays `decklightCast: 1`: every addition (`hidden`, `typeSpeed`, `input`,
249
+ sleep steps) is an optional field that old casts simply lack and the player treats as
250
+ absent — no compatibility break in either direction.
251
+
252
+ ### 7.3 Player (runtime; zero native deps)
253
+
254
+ ```html
255
+ <div class="terminal" data-cast="casts/demo.cast.json" data-mode="step"></div>
256
+ <!-- or, for decks that must work on file:// (fetch of local files is blocked): -->
257
+ <div class="terminal" data-cast-inline="#my-cast" data-mode="step"></div>
258
+ <script type="application/json" id="my-cast">{ "decklightCast": 1, … }</script>
259
+ ```
260
+
261
+ - `data-mode="step"` (default): registers a build provider — each advance **types the command**
262
+ (synthesized keystrokes, 80–135ms jitter, each landing with a synthesized switch sound: data-type-sound="creamy" (default) | "thocky" | "clacky" | "off") then streams its real
263
+ output with recorded pacing compressed to ≤2.5s per step (`data-max-step` override).
264
+ Provider is idempotent: `apply(i)` renders steps `< i` instantly-complete, animates step `i` if
265
+ reached by a forward advance, clears the rest.
266
+ - **Typing sound**: a subtle synthesized key click accompanies each typed character
267
+ (WebAudio — a ~35ms bandpassed noise tick with jittered pitch/level, no asset;
268
+ spaces land deeper, with more bass). Three voicings, `data-type-sound="creamy"`
269
+ (default) | `"thocky"` | `"clacky"`, or `"off"` to mute. A `♪ voice` titlebar button
270
+ lets the presenter cycle creamy → clacky → thocky → off live; that choice persists per
271
+ deck in localStorage and overrides the authored value. Degrades to silence where audio
272
+ is unavailable.
273
+ - **Typing speed**: `data-type-speed` is a `1` (slow) … `10` (fast) scale, default `5`
274
+ (the classic pace; the mapping is exponential — 1 ≈ ⅓×, 10 ≈ 4×, and factor 1 reads at
275
+ ~55 wpm). A `⌨ n wpm` titlebar button lets the presenter cycle the speed live through
276
+ words-per-minute presets (30 … 160); that choice persists per deck in localStorage and
277
+ overrides the authored value on every terminal (which maps to the nearest preset).
278
+ Per-step `typeSpeed` (a multiplier) composes on top.
279
+ - `data-mode="play"`: timeline playback with play/pause, speed control, original timing
280
+ (`sleep` steps pause the timeline; `hidden` steps never play in either mode).
281
+ - `data-poster="N"`: the terminal arrives with its first N playable steps already
282
+ rendered; poster steps are excluded from the build sequence (provider count =
283
+ playable − N, `apply(i)` shows N + i).
284
+ - Interactive `input` records play back as typed keystrokes at their recorded position
285
+ in the output stream (secrets appear as `▓▓▓`); per-step `typeSpeed` multiplies both
286
+ command and input typing.
287
+ - Rendering: **ANSI subset renderer** (owned, no xterm.js): SGR 0/1/2/3/4/7/22/23/24/27,
288
+ 30–37/40–47/90–97/100–107, 38;5/48;5 (256), 38;2/48;2 (truecolor), `\r` overwrite, `\b`,
289
+ EL (`\x1b[K`), simple cursor-forward. Full-screen apps out of scope (documented).
290
+ 256/truecolor pass through; the 16 named colors map to the theme's `--ansi-*` tokens.
291
+ - A scrollback cap (default 24 rows visible, older lines scroll) with themed chrome
292
+ (`--term-*`), rounded window with fake traffic lights (theme may hide via `--term-chrome: none`).
293
+
294
+ ### 7.4 asciicast v2 interop
295
+
296
+ - **Export**: `decklight export <cast.json> [-o out.cast]` flattens a decklight cast to
297
+ asciicast v2 NDJSON — prompt and typed command injected as output events
298
+ (deterministic 45ms/char typing), step boundaries as `m` marker events, hidden steps
299
+ omitted, sleep steps as pure time gaps. Unlocks the asciinema ecosystem:
300
+ `agg out.cast demo.gif` for READMEs, asciinema.org sharing, asciinema-player embeds.
301
+ - **Import**: `data-cast` also accepts a plain asciicast v2 file (detected by shape,
302
+ not extension). Imported streams are `raw` — they already contain prompts and echoed
303
+ input, so the player injects nothing. With `m` markers the recording is step-capable
304
+ (one build per marker); without markers it plays as a single timeline (`data-mode="step"`
305
+ falls back to `play`).
306
+
307
+ ## 8. Presenting & output
308
+
309
+ - Keyboard: `→/←/Space` steps+slides, `Home/End`, `O` overview grid, `B` blackout, `F` fullscreen, `T` theme picker (`,`/`.` cycle the theme, `[`/`]` cycle the font), `/` command palette, `V` narration (`N` picks track / live voice / tone; `P` pauses/resumes; `<`/`>` change the voice speed in 0.25× steps, 0.25–2×, persisted per deck — YouTube's shortcut), `C` captions, `D` debug log, `G` slide finder (deliberately not `⌘F` — browser find stays untouched), `?` help overlay.
310
+ - **Captions** (`C`): a YouTube-style bar at the bottom showing the current notes segment — the same ⟨CLICK⟩-segmented text the live voice speaks — synced to slide/step, with narration on or off. Slides/steps without notes show no bar. Persists per deck in localStorage.
311
+ - **Transcript** (palette → Transcript…): the deck's full spoken script — every slide's notes segments in order, slide titles jump on click — with export to plain text or markdown (`<deck>-transcript.txt|.md`). Also programmatic: `instance.transcript.text()` / `.markdown()` / `.open()`.
312
+ - **Edit mode** (`E`): with the deck served by `decklight edit <deck.html>` (localhost; `GET /edit/ping`, SSE `GET /edit/events`, `POST /edit/notes {slide, text}`), E opens a notes editor — ⟨CLICK⟩-separated plain text — whose Save rewrites the slide's `<aside class="notes">` in the file (one `<p>` per segment, HTML-escaped; the aside is inserted if the slide had none). The server watches the deck file and broadcasts a reload to every connected browser on ANY change — the player's edits and external editors alike — and the `#/slide/step` hash restores the position. Markdown-authored slides decline the editor (their notes live in the template). `file://`-opened decks probe the server at its default localhost port (CORS-open endpoints, like the tts bridge) so the printed URL and a double-clicked file both work; `config.edit.url` overrides, and a basename guard refuses a server that's editing a different deck.
313
+ - **Debug log** (`D`): a passive monospace panel over the deck (keys keep driving the presentation) showing a timestamped event stream — ready/slide/build with direction, theme and font applies, narration on/off and config changes, every TTS call with its duration and estimated cost (`slide 3 seg 1 · Alnilam · 214 chars → 2.4s · ~$0.0041`, previews included) and failures, and window `error` events — plus a live state line (slide/step/theme/narration incl. the active voice · tone or track, the voice rate, and the running TTS spend). Cost is an ESTIMATE: the Gemini API returns token counts (`usageMetadata`), never dollars; the bridge prices them at published Vertex list rates, sends `x-tts-cost` on each fresh synthesis (0 on cache replays), and prints a per-call line plus session total on its console. Events ring-buffer (last 200) from init, so the panel shows history from before it was opened.
314
+ - **Font cycling**: `[`/`]` walk a curated list of offline-safe system stacks (sans, rounded, humanist, geometric, two serifs, slab, mono; entry 0 = the theme's own type) applied to `--font-body` + `--font-heading` as inline root properties — they win over any theme and survive theme switches. The choice persists per deck path in localStorage and is restored before the first layout pass; every change re-measures pinned titles and re-runs the overflow guardrail (type metrics differ). `instance.cycleFont(±1)` programmatically.
315
+ - Touch: swipe navigation.
316
+ - URL: `#/<slide>[/<step>]`; back/forward supported. `?theme=<name>` loads any theme at startup.
317
+ - **Theme switching**: the theme is the stylesheet link into `themes/`; the engine swaps its href in place, so every token cascades and the deck restyles live. `T` opens the **theme picker**: the theme list (config `themes: [...]`, defaulting to the shipped set baked in at build time) beside a live preview of the *current slide at the current step* — a real embedded deck (`?embedded&theme=<name>#/<slide>/<step>`). `↑/↓`/hover browse (debounced), `Enter`/click applies, `Esc` closes. **Packs** (themes/packs.json, baked in at build; the build fails if a shipped theme is missing from the manifest): the picker opens on the pack list (name + count; the active theme's pack is marked), `Enter` drills in, the `← packs` row or `Esc` goes back, `✳ all themes` flattens; saved-custom and generated themes form dynamic packs at the end. An active filter always searches globally and tags each hit with its pack. **Pack-aware cycling**: `,`/`.` walk the pack-grouped order; a step that would cross into another pack pends instead of applying — a toast names the pack and target theme, the same key confirms, the opposite key or `Esc` cancels, and the pending step times out after 4s. **Quick filter**: printable keys type into a filter bar that narrows the list (substring match; the generate row hides while a filter is active); `Backspace` edits, `Esc` clears the filter first and closes on the second press. Because keystrokes feed the filter, the picker has no letter shortcuts (`⌃T` re-rolls the generate-row candidate; the former `R`/`T` bindings are gone). The applied choice persists per deck path in `localStorage`; **embedded instances never persist** (previews can't pollute the saved choice).
318
+ - **Inline-theme mode** (bundled single-file decks): when `<style data-theme="name">` blocks exist, they replace the link — `applyTheme` toggles which block applies (via `media="not all"`; the HTML `disabled` attribute on `<style>` is non-functional per spec, and the engine normalizes either form at init). The picker lists the embedded names (config `themes` narrows), `?theme=` works, and everything else is unchanged.
319
+ - **Theme generator**: `⌃T` generates a brand-new contract-complete theme (§5 note) and applies it instantly — press again to re-roll. The roll lives as a `<style data-theme data-generated>` block appended last in `<head>` (wins the cascade over the link/inline base); cycling `<`/`>` or picking another theme deactivates it but keeps it in the theme list under its autoname (`gen-<word>-<hex4>`) until the next roll replaces it. The picker's first row is **“✨ Generate new…”**: selecting it rolls a candidate and previews it live like any other theme (`⌃T` re-rolls while selected; `Enter` applies). Previews for generated and saved-custom themes carry their tokens in the URL — `?gen=<base64url {name, tokens}>` — which the engine applies statelessly at init (works on `file://` and inside bundles). The preview deck loads **once per picker session**; subsequent selections are postMessage'd into the embedded instance (`{__decklightPreview: {theme|gen}}`, parent-origin-guarded) and applied in place — no document reload per candidate, which matters in bundles where each reload would re-parse the whole payload. `⌃⇧T` **saves** the applied generated theme: prompts for a name (sanitized `[a-z0-9-]{1,40}`, `custom-` prefixed on collision with a shipped name), persists `{name → tokens}` into `localStorage['decklight-custom-themes']`, and downloads `<name>.css` (a normal theme file). Saved customs appear in the theme list and picker (tagged “custom”), survive reload, and apply via the same inline-style mechanism — but localStorage is per-origin/per-browser: **the downloaded .css is the portable artifact** (drop it into `themes/` and commit). Unsaved generated autonames are never persisted as the deck's theme choice.
320
+ - **Brand logo**: `init({ logo: { onLight, onDark, src?, height?, position? } })` renders a mark as chrome on every slide (default `bottom-left`; also `top-left|top-right|bottom-right`; default height 30px). `onLight`/`onDark` are the variants for light/dark canvases: the engine reads the applied theme's real background luminance (first gradient stop for gradient canvases), sets `data-canvas="dark|light"` on the root, and the matching variant shows — following theme cycling, the picker, and generated themes ( `src` alone shows always). Refs resolve as `'#id'` (clones an inline element — bundle- and `file://`-safe, the `data-cast-inline` idiom), `'<svg…'` raw markup, or an `<img>` URL. In `?print`, every slide gets its own copy. **Hero variant**: `data-logo` on a section prepends a larger in-flow copy of the mark above the slide's content (default 96 design px; `data-logo="128"` overrides) — module openers and cover slides. Hero slides hide the corner chrome (and skip the print copy), the mark doesn't count as pinnable content, and the same on-light/on-dark variant switching applies.
321
+ - **Narration**: `V` toggles voice-over; `N` opens the picker (persisted per deck). Two sources. **Recorded**: pre-rendered per-slide audio (`<dir>/slide-NN.m4a`, 1-based like `state.slide`), synced to slide changes; the deck configures `narration: { files: '<dir>' }` or `[{ label, dir }, …]` for several takes. **Live voice**: the player synthesizes each slide's notes on the fly through the local bridge (`decklight tts`, default `http://127.0.0.1:8787/tts`, override via `narration.liveUrl`) — the picker drills tracks → Gemini voice (30 prebuilt, flavor-tagged) → tone (six presets or a custom typed instruction, sent as an in-prompt delivery-style prefix). Every voice row carries a ▶ **preview** that speaks an editable test sentence (default "Hey, this is Decklight", input at the top of the voices view, ↺ restores the default) through the bridge in that voice; tone rows preview the drafted voice in that delivery style, and the custom-tone input has a ▶ to audition a typed instruction before committing it. After a preview plays, the rest of its roster prefetches sequentially in the background (all voices neutrally, or all tones for the drafted voice). Two preview caches: the default sentence's cache is permanent once built; the custom sentence's cache holds exactly one sentence and is swapped (old audio freed) when the text changes. **Build-synced auto-advance**: the ⟨CLICK⟩ markers that segment the notes for the speaker view segment the audio too — segment k is synthesized as its own clip and narrates build step k (0 = arrival); when a clip ends the deck reveals the next build, and after the last segment it moves to the next slide, so a deck with well-segmented notes presents itself beat by beat. A segment with no words advances after a short beat; a slide with no notes is skipped; manual navigation mid-clip wins over the pending advance and re-syncs the voice to the new step. **`data-narration="hold"`** on a section marks it interactive (quiz, exercise, live demo): narration plays whatever notes it has and builds still sync, but the deck never auto-advances *off* it — the presenter moves on manually and narration resumes on the next slide. The spoken unit is a **sentence**: each ⟨CLICK⟩ segment splits into sentences, every sentence is its own TTS call and cache entry (slide, segment, sentence, voice, style), playback chains them and advances only after the segment's last one — so first audio arrives after one short synthesis, never a whole paragraph. While narration is on, a **lookahead buffer** keeps the next 10 sentences synthesized in the background (a few parallel low-priority workers, window re-derived from the current position, results landing in the same cache); an unreachable bridge toasts once and pauses the buffer until the next event. `⇧V` downloads per-slide `slide-NN.wav` files **stitched from this same sentence cache** (short silences joining sentences and builds) — already-heard clips cost nothing, only unheard sentences synthesize. **`P` pauses/resumes** narration — audio freezes mid-sentence and resumes exactly there, and auto-advance holds while paused. Captions (`C`) follow the voice **sentence by sentence** during live narration (segment-level when narration is off). With nothing configured, `V` opens the picker instead of failing. Files are generated by `tools/voiceover.mjs`: notes → optional local-LLM rewrite → `--engine piper` (local neural TTS; `--voice` = piper model name) or `--engine gemini` (gemini-2.5-pro-tts on Vertex AI; `--voice` = prebuilt voice name). The built-in speech-synthesis voices were removed — not good enough. `?voiceover` starts narration on the first user gesture. `instance.toggleNarration()` programmatically.
322
+ - **Command palette**: `/` opens a Claude-style palette — every command with its shortcut, type-to-filter, `Enter` runs, `Esc` clears then closes. Argument commands (Theme…, Font…, Narration voice…, Module…, Find slide…) drill into their pickers; contextual commands appear only when applicable (Save generated theme, Module). Inline arguments work: `goto 27` — or just typing `27` — surfaces a “Go to slide 27 / N” row (clamped to the deck), and selecting the bare “Go to slide…” command keeps the palette open with `goto ` prefilled. Text matching no command falls back to a “Search slides for …” row that opens the finder with the query prefilled.
323
+ - **Slide finder**: reached from the palette (`/` → Enter, or the search fallback) — a find-a-slide overlay with the picker's anatomy — a query bar and result list on the left, a live preview of the selected slide on the right. Typing filters as an AND over the query's words against each slide's text; slides whose **title** contains every word rank first, body-only matches follow, and every match is listed as `<slide number> · <title>` (slides without a heading fall back to their leading text). `↑/↓`/hover browse (the preview swaps live), `Enter`/click jumps to the slide, `Backspace` edits, `Esc` clears the query then closes. The preview reuses the picker's lazy embedded-deck mechanism — the iframe boots once (carrying the active theme, generated/custom included via `?gen=`), then selections postMessage `{__decklightPreview: {goto: [slide, step]}}` into it; no reload per candidate.
324
+ - **Playlist (multi-deck navigation)**: `Decklight.init({ playlist: { modules: [{title, href}…], index: n } })`. Advancing past the last build of the last slide navigates to the next module (`href#/1/0`); reversing before slide 1 goes to the previous module's end (`href#/999/999` — oversized hashes clamp to the last slide/step). `M` opens the **module menu** (list overlay, current module ✓, `↑/↓` + `Enter` navigates, `Esc`/`M` closes; the slide-number chrome shows the module title and opens the menu on click). Works on `file://` with relative hrefs; embedded instances never chain.
325
+ - **Speaker view**: `S` opens a popup (synced via BroadcastChannel): current + next slide thumbnails, notes (with `⟨CLICK⟩` markers highlighted as the matching build lands), elapsed timer, build step list (provider labels). **Rehearse mode**: pressing `S` again (in the deck or in the popup; the header badge also toggles) swaps the prose notes for the slide's `aside.rehearse` cue cards (§1) — rendered large and bold, one cue per segment, same said/now/next highlighting. Slides without rehearse notes fall back to the full prose. `S` in the deck only opens a new popup when none is connected; while one is open it toggles the mode.
326
+ - **Print/PDF**: `?print` renders all slides sequentially, every build complete, terminal casts fully expanded, one slide per page (`@media print` CSS). No JS needed after layout.
327
+ - **Overflow guardrail**: content that exceeds the slide flex-shrinks into a scroll box and reads as clipped. The engine warns (`console.warn`) and marks the section with a `data-overflow` attribute — on each slide activation, and for the whole deck in `?print` — so authoring agents can assert `[data-overflow]` is absent in their headless verification.
328
+ - **Pinned titles**: `pinTitles: false | true | <px>` keeps slide titles at one vertical position instead of drifting with content height. `true` pins at **99px** from the stage top (design coordinates — the natural title position of the course's "The Single-Agent Limit" diagram slide, the chosen reference); a number pins at that Y. The leading `h1`/`h2` of each **pinnable** section is absolutely positioned at the pin Y; the section reserves `pin Y + measured title height + 18px` of top padding so the remaining content centers below. Pinnable = has a leading `h1`/`h2` AND content beyond it (`ul, ol, svg, pre, table, .terminal, img, .columns` outside the notes) — title cards and quote/statement slides stay centered. Per-slide: `data-pin` forces a pin (even when the config is off), `data-pin="none"` opts out, `data-pin="<px>"` overrides the Y. Titles are re-measured on `sync()` and when webfonts finish loading; print uses the same layout. A detected **subtitle** (§1) joins the pinned header block — absolutely positioned directly beneath the title (6px gap) and included in the reserved padding, so content centers below title + subtitle.
329
+ - **Terminal footprint**: a terminal's screen area has a stable size — a **16:9 aspect floor** (width-driven) clamped by the `data-rows` cap as the max — so the box arrives full-size before anything plays and never resizes as steps stream in (output beyond the box scrolls). Real print expands casts fully.
330
+ - `Decklight.init(config)` options: `transition`, `hash` (default true), `controls` (default true: prev/next chevrons + progress bar), `slideNumber` (default `false | 'n' | 'n/N'`), `width/height` design resolution (default 1280×720, scaled to fit), `themes` (array of theme names for the picker/cycle; default: all shipped themes), `playlist` (multi-deck navigation, above), `pinTitles` (pinned titles, above), `concepts` (diagram concept-color pinning, §3), `logo` (brand mark, above).
331
+ - **Single-file bundling**: `decklight bundle <deck.html> [-o out.html] [--themes current|all|a,b,…]` flattens a deck into one self-contained HTML — runtime and structure CSS inlined, selected themes embedded as inline-theme blocks, `data-cast` terminals converted to `data-cast-inline`, images to data: URIs. Playlist links can't resolve inside a single file; the CLI lists them as a notice.
332
+ - **Merged single-file presentation**: `decklight bundle <deck.html> --all [--title "…"]` follows the deck's playlist and concatenates EVERY module's sections into one deck (explicit form: `decklight bundle a.html b.html … -o one.html`). Each module's first section is marked `data-module="<title>"`; embedded cast ids are prefixed per module to stay unique; relative asset refs are rebased onto the first deck's directory; the per-module `playlist` config is stripped. **In-file module navigation**: when sections carry `data-module`, the `M` menu lists those markers and Enter/click `goto()`s the marker's slide (no page loads), and the chrome module tag shows the module of the current slide (nearest preceding marker). Marker mode takes precedence over `config.playlist`.
333
+
334
+ ## 9. Public JS API
335
+
336
+ ```js
337
+ Decklight.init(config) → instance
338
+ instance.next() / .prev() / .goto(slide, step)
339
+ instance.on('slide'|'build'|'ready', fn)
340
+ Decklight.registerBuildProvider(el, provider) // available pre- and post-init
341
+ instance.state → { slide, step, totalSlides }
342
+ instance.sync() // re-scan DOM (for dynamic decks)
343
+ instance.theme(name) // switch theme programmatically
344
+ instance.themePicker.open() / .close()
345
+ instance.generateTheme() // ⌃T programmatically; returns the autoname
346
+ instance.cycleFont(dir) // [ / ] programmatically (±1)
347
+ instance.toggleNarration() // V programmatically
348
+ instance.saveGeneratedTheme(name?) // ⌃⇧T; a name argument skips the prompt
349
+ ```
350
+
351
+ ## 10. Repository layout & tooling
352
+
353
+ ```
354
+ decklight/
355
+ SPEC.md README.md package.json
356
+ src/core/ engine: init, nav, builds, transitions, auto-animate, notes, print, svg-ns
357
+ src/md/ markdown slide support (marked)
358
+ src/code/ highlight bundling + line stepping provider
359
+ src/terminal/ ansi.mjs (parser), player.mjs (provider + modes)
360
+ cli/ decklight.mjs (dispatcher: rec/refresh/export/bundle) + rec.mjs, bundle.mjs
361
+ themes/ 30 × <name>.css + gallery.html
362
+ dist/ decklight.js (IIFE, global Decklight), decklight.css
363
+ demo/ kitchen-sink.html + casts/
364
+ test/ node:test units (ansi, md, builds math, cast format) + render.mjs (headless Chrome assertions) + contrast.mjs (theme validation)
365
+ ```
366
+
367
+ - Build: `npm run build` = esbuild bundle (`src/index.js` → `dist/decklight.js`, minified + sourcemap) + CSS copy. Node ≥ 20. Runtime has **zero** runtime dependencies (marked + highlight.js are bundled at build time); `node-pty`, `js-yaml` are CLI-only deps.
368
+ - Verification culture: `npm test` runs units; `npm run verify` builds, launches headless Chrome against `demo/kitchen-sink.html`, and asserts: slide count, build counts per slide, provider steps, ANSI render output, theme token presence, no console errors.
369
+
370
+ ## 11. Non-goals (v1)
371
+
372
+ Vertical slide nesting · full terminal emulation (vim/htop) · multiplex/follow-along · plugin system (providers + events cover extension) · PPTX export · mobile authoring.