wand-kit 0.2.0 → 0.3.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.
package/lib/components.js CHANGED
@@ -18,12 +18,13 @@
18
18
  * content lines. A job's `pages(c)` composes with them and may add its own
19
19
  * CSS on top; nothing stops it writing raw markup where no component fits.
20
20
  */
21
+ import path from "node:path";
21
22
  import { esc } from "./content.js";
22
23
  import { url } from "./brand.js";
23
24
 
24
25
  export const ARROW = `<svg class="arrow deco" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 1 L9 5 L2 9 Z"/></svg>`;
25
26
 
26
- export function bind({ brand, surface, job, content }) {
27
+ export function bind({ brand, surface, job, content, dir: jobDir = process.cwd() }) {
27
28
  let n = 0;
28
29
  const L = content.L;
29
30
  const cls = (c) => (c ? ` ${c}` : "");
@@ -47,13 +48,15 @@ export function bind({ brand, surface, job, content }) {
47
48
  * A page (a slide, a sheet, a card) with the chrome: the tag and eyebrow top
48
49
  * left, the mark top right, the footer with the running line and the page
49
50
  * number. `art` is a brand art role ("cover", "overlay", "closing") or a
50
- * file path; `notes` become speaker notes in the .pptx.
51
+ * file path; `notes` become speaker notes in the .pptx and the presenter
52
+ * view; `seconds` is the slide's share of the talk, for the presenter's
53
+ * pace; `motion: "none"` keeps a slide still when the deck animates.
51
54
  */
52
- const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true } = {}) => {
55
+ const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "" } = {}) => {
53
56
  n += 1;
54
57
  const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(art)) : null;
55
58
  const eb = job.tag && eyebrow ? `${esc(job.tag)}<span class="bar">|</span>${esc(eyebrow)}` : esc(job.tag || eyebrow || "");
56
- return `<section class="page ${dark ? "dark" : "light"}${cls(k)}" data-n="${n}"${notes ? ` data-notes="${esc(notes)}"` : ""}>
59
+ return `<section class="page ${dark ? "dark" : "light"}${cls(k)}" data-n="${n}"${notes ? ` data-notes="${esc(notes)}"` : ""}${seconds ? ` data-seconds="${seconds}"` : ""}${motion ? ` data-motion="${esc(motion)}"` : ""}>
57
60
  ${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
58
61
  ${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
59
62
  <div class="content">${body}</div>
@@ -104,7 +107,8 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
104
107
  return `<div class="card q${emph ? " emph" : ""}${cls(k)}"><p>${esc(text)}</p>${who(name, role)}</div>`;
105
108
  };
106
109
 
107
- const stat = ({ v, k, hero = false }) => `<div class="stat${hero ? " hero" : ""}"><div class="v">${esc(v)}</div><div class="k">${esc(k)}</div></div>`;
110
+ /** A figure and its key; `count` makes the figure count up as its slide arrives in a talk. */
111
+ const stat = ({ v, k, hero = false, count = false }) => `<div class="stat${hero ? " hero" : ""}"><div class="v"${count ? ' data-a="count"' : ""}>${esc(v)}</div><div class="k">${esc(k)}</div></div>`;
108
112
  const stats = (items) => `<div class="stats">${items.map(stat).join("")}</div>`;
109
113
  const delta = (from, to) => `<div class="delta"><span class="from">${esc(from)}</span>${ARROW}<span class="to">${esc(to)}</span></div>`;
110
114
 
@@ -130,6 +134,99 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
130
134
  const icon = (name, k = "") => brand.glyph(name).replace("<svg", `<svg class="icon deco${cls(k)}" aria-hidden="true"`);
131
135
  const tile = (name) => `<div class="tile deco">${icon(name)}</div>`;
132
136
 
137
+ /* ---------------------------------------------------------- motion */
138
+
139
+ /** An entrance, as attributes for raw markup: `<div${c.enter("pop", 2)}>`. Kinds: rise (default), fade, pop, grow (bars, up), widen (bars, across), draw (an SVG path with pathLength="1"), count. */
140
+ const enter = (kind = "", i = null) => ` data-a="${esc(kind)}"${i != null ? ` style="--i:${+i}"` : ""}`;
141
+ /** Held back until the presenter moves on; steps of one number come in together. */
142
+ const step = (nth, html, { a = "", tag: t = "div", cls: k = "" } = {}) => `<${t} data-step="${+nth}"${a ? ` data-a="${esc(a)}"` : ""}${k ? ` class="${k}"` : ""}>${html}</${t}>`;
143
+ /** A figure that counts up from zero as its slide arrives. */
144
+ const countup = (text, k = "") => `<span data-a="count"${k ? ` class="${k}"` : ""}>${esc(text)}</span>`;
145
+
146
+ const fileUrl = (f) => (/^(file|https?|data):/.test(f) ? f : url(path.resolve(jobDir, f)));
147
+ /** A clip that plays while its slide is on (muted, looping by default); the poster is what the PDF, the PNGs and the .pptx show. */
148
+ const video = ({ src, poster, autoplay = true, loop = true, label = "", cls: k = "" }) => {
149
+ if (!poster) throw new Error(`video ${src}: a poster is required, it is the frame every static export shows`);
150
+ return `<figure class="media video${cls(k)}"${autoplay ? ' data-autoplay="1"' : ""}${label ? ` aria-label="${esc(label)}"` : ""}><img class="poster" src="${fileUrl(poster)}" alt=""><video src="${fileUrl(src)}" muted playsinline preload="none"${loop ? " loop" : ""}></video></figure>`;
151
+ };
152
+ /**
153
+ * A live page (a simulation, a product walk-through) in a frame on the slide.
154
+ * `src` is one self-contained HTML file; it travels inside the deck. It may
155
+ * expose window.wandDemo = { play, pause, reset } for the D and R keys.
156
+ * `min` is the size it is laid out at before being scaled into the frame.
157
+ */
158
+ const demo = ({ src, poster, min = [1280, 720], label = "Live demo", cls: k = "" }) => {
159
+ if (!poster) throw new Error(`demo ${src}: a poster is required (node wand.js poster ${src}), it is the frame every static export shows`);
160
+ return `<figure class="media demo${cls(k)}" data-demo-src="${fileUrl(src)}" data-min="${min[0]}x${min[1]}" aria-label="${esc(label)}"><img class="poster" src="${fileUrl(poster)}" alt=""><div class="screen"></div></figure>`;
161
+ };
162
+
163
+ /* ------------------------------------------------------- storyboard */
164
+
165
+ const tc = (t) => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, "0")}`;
166
+ /**
167
+ * A quick frame in the brand's colours, for a shot with no artwork yet:
168
+ * people as dots, software as labelled boxes, agents as sparks, left to
169
+ * right with arrows between, and a line under them.
170
+ */
171
+ const sketch = ({ people = 0, apps = [], agents = 0, label = "", arrows = true } = {}) => {
172
+ const groups = [];
173
+ if (people) groups.push((x) => { const n = Math.min(people, 12), cols = Math.ceil(Math.sqrt(n)), r = 34, g = 96, rows = Math.ceil(n / cols);
174
+ return Array.from({ length: n }, (_, i) => `<circle class="sk-p" cx="${x + (i % cols - (cols - 1) / 2) * g}" cy="${470 + (Math.floor(i / cols) - (rows - 1) / 2) * g}" r="${r}"/>`).join(""); });
175
+ if (apps.length) groups.push((x) => apps.slice(0, 4).map((a, i, all) => { const h = 104, y = 470 + (i - (all.length - 1) / 2) * (h + 26);
176
+ return `<g class="sk-app"><rect x="${x - 190}" y="${y - h / 2}" width="380" height="${h}" rx="18"/><text x="${x}" y="${y + 20}" text-anchor="middle">${esc(a)}</text></g>`; }).join(""));
177
+ if (agents) groups.push((x) => { const n = Math.min(agents, 6); return Array.from({ length: n }, (_, i) => { const cx = x + ((i % 3) - (Math.min(n, 3) - 1) / 2) * 150, cy = 470 + (Math.floor(i / 3) - (n > 3 ? .5 : 0)) * 150, R = 58, r = 16;
178
+ return `<path class="sk-s" d="M${cx} ${cy - R}Q${cx + r} ${cy - r} ${cx + R} ${cy}Q${cx + r} ${cy + r} ${cx} ${cy + R}Q${cx - r} ${cy + r} ${cx - R} ${cy}Q${cx - r} ${cy - r} ${cx} ${cy - R}Z"/>`; }).join(""); });
179
+ const xs = groups.length === 1 ? [960] : groups.length === 2 ? [600, 1320] : [400, 960, 1520];
180
+ const arrow = (x) => `<path class="sk-arrow" d="M${x - 40} 452h56v-22l40 40-40 40v-22h-56z"/>`;
181
+ const art = groups.map((g, i) => g(xs[i])).join("") + (arrows ? xs.slice(1).map((x, i) => arrow((xs[i] + x) / 2)).join("") : "");
182
+ return `<svg class="sk" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid meet" aria-hidden="true">${art}${label ? `<text class="sk-label" x="960" y="900" text-anchor="middle">${esc(label)}</text>` : ""}</svg>`;
183
+ };
184
+
185
+ /**
186
+ * A storyboard from a script: the cover, the run of show, one page per shot.
187
+ * Timing is scratch timing: each voiceover line lasts its words at `wpm`,
188
+ * with `lead` before and `tail` after; a shot without voiceover holds for
189
+ * `hold` seconds; `seconds` on a shot overrides the estimate. A frame lands
190
+ * on its `cue` word. The timeline is kept for the CSV, the captions and the
191
+ * player in the HTML.
192
+ * A shot: { act, name, vo, line, camera, motion, onscreen, sound, next,
193
+ * lead, tail, hold, seconds, frames: [{ cue, caption, art | img | sketch }] }
194
+ */
195
+ const storyboard = ({ title, lede = "", wpm = 150, target = 0, shots = [], dark = true, cover: withCover = true, index = true, audio = "" }) => {
196
+ let t = 0;
197
+ const timeline = shots.map((s, i) => {
198
+ const vo = String(s.vo ?? "").trim(), words = vo ? vo.split(/\s+/).length : 0;
199
+ const voDur = vo ? +(s.seconds ?? words / (wpm / 60)).toFixed(1) : 0;
200
+ const lead = vo ? s.lead ?? 0.5 : 0, tail = vo ? s.tail ?? 0.5 : 0;
201
+ const inT = t, voIn = inT + lead, voOut = voIn + voDur, out = vo ? voOut + tail : inT + (s.hold ?? s.seconds ?? 3);
202
+ t = +out.toFixed(1);
203
+ const fr = (s.frames && s.frames.length ? s.frames : [{}]).slice(0, 4).map((f, j, all) => {
204
+ const at = f.cue && vo ? vo.toLowerCase().indexOf(String(f.cue).toLowerCase()) : -1;
205
+ // the first frame is on screen from the cut; the others land on their cue words
206
+ const ft = j === 0 ? inT : at >= 0 ? voIn + voDur * (at / vo.length) : voIn + (voDur * j) / all.length;
207
+ return { ...f, id: `${String(i + 1).padStart(2, "0")}${"ABCD"[j]}`, t: +ft.toFixed(1), placeholder: !(f.art || f.img || f.sketch) };
208
+ });
209
+ return { ...s, n: i + 1, vo, in: +inT.toFixed(1), voIn: +voIn.toFixed(1), voOut: +voOut.toFixed(1), out: t, dur: +(t - inT).toFixed(1), frames: fr };
210
+ });
211
+ const total = t;
212
+ job.__board = { wpm, target, total, audio: audio ? path.resolve(jobDir, audio) : "", shots: timeline.map(({ n, act, name, vo, in: i, voIn, voOut, out, dur, frames }) => ({ n, act: act || "", name: name || "", vo, in: i, voIn, voOut, out, dur, frames: frames.map(({ id, t: ft, cue, placeholder }) => ({ id, t: ft, cue: cue || "", placeholder })) })) };
213
+
214
+ const art = (f) => f.art ? f.art : f.img ? `<img src="${fileUrl(f.img)}" alt="">` : f.sketch ? sketch(f.sketch) : `<div class="todo"><span>${esc(f.cue ? `on ‘${f.cue}’` : "frame")}</span></div>`;
215
+ const DIR = [["line", "The line"], ["camera", "Camera"], ["motion", "Motion"], ["onscreen", "On screen"], ["sound", "Sound"], ["next", "Into next"]];
216
+ const shotPage = (s) => page({ eyebrow: s.act || "", dark, cls: "shot", notes: s.vo, body: `
217
+ <div class="shot-head"><div class="shot-id"><span class="tag">Shot</span><span class="no">${String(s.n).padStart(2, "0")}</span></div>
218
+ <div class="shot-title"><h2>${esc(s.name || "")}</h2><p class="tc">${tc(s.in)} → ${tc(s.out)} · ${s.dur.toFixed(1)} s${s.vo ? ` · voice ${(s.voOut - s.voIn).toFixed(1)} s` : ""}</p></div></div>
219
+ ${s.vo ? `<blockquote class="vo">${esc(s.vo)}</blockquote>` : `<p class="vo none">No voiceover; holds ${s.dur.toFixed(1)} s</p>`}
220
+ <div class="shot-body n${s.frames.length} ${s.frames.length <= 2 ? "side" : "stack"}"><div class="frames n${s.frames.length}">${s.frames.map((f) => `<figure class="frame${f.placeholder ? " placeholder" : ""}" data-frame="${f.id}"><div class="frame-art">${art(f)}</div><figcaption><b>${f.id}</b> · ${tc(f.t)}${f.cue ? ` · on ‘${esc(f.cue)}’` : ""}${f.caption ? `<span>${esc(f.caption)}</span>` : ""}</figcaption></figure>`).join("")}</div>
221
+ ${DIR.some(([k]) => s[k]) ? `<dl class="direction">${DIR.filter(([k]) => s[k]).map(([k, l]) => `<div><dt>${l}</dt><dd>${esc(s[k])}</dd></div>`).join("")}</dl>` : ""}</div>` });
222
+
223
+ const out = [];
224
+ if (withCover) out.push(cover({ label: "Storyboard", title: esc(title), lede, meta: `${timeline.length} shots · ${tc(total)}${target ? ` of ${tc(target)}` : ""} · scratch timing at ${wpm} words a minute` }));
225
+ if (index) for (let i = 0; i < timeline.length; i += 9) out.push(page({ eyebrow: "Run of show", dark, cls: "run", body: `${h("h2")(i ? "Run of show, continued" : "Run of show")}${table({ head: ["Shot", "Act", "Name", "In", "Length"], rows: timeline.slice(i, i + 9).map((s) => [String(s.n).padStart(2, "0"), s.act || "", s.name || "", tc(s.in), `${s.dur.toFixed(1)} s`]) })}` }));
226
+ timeline.forEach((s) => out.push(shotPage(s)));
227
+ return out;
228
+ };
229
+
133
230
  return {
134
231
  L, lines: content.lines, esc, accent, split, cap, raw: (s) => s, ARROW, arrow: ARROW,
135
232
  page, sec, h1: h("h1"), h2: h("h2"), h3: h("h3"), h4: h("h4"), h5: h("h5"),
@@ -137,6 +234,7 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
137
234
  cols, card, chip, strip, phase, flow, table, who, quote, stat, stats, delta, steps, callout, list: listOf,
138
235
  cover, closing, chapter, img, icon, tile, mark, wordmark,
139
236
  asset: brand.assetUrl, art: brand.artUrl,
237
+ enter, step, countup, video, demo, sketch, storyboard,
140
238
  brand: brand.name, surface: surface.name, count: () => n,
141
239
  };
142
240
  }
@@ -310,4 +408,52 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
310
408
  .centre{text-align:center;align-items:center}
311
409
  .grow{flex:1}
312
410
  .spread{justify-content:space-between}
411
+
412
+ /* media: the poster is what a static export shows; the runtime plays the rest */
413
+ .media{position:relative;margin:0;overflow:hidden;border-radius:calc(var(--m-radius) * var(--radius-k));aspect-ratio:16/9;background:var(--track);border:var(--card-stroke) solid var(--stroke)}
414
+ .media>.poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
415
+ .media>video{display:none;position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
416
+ .media>.screen{position:absolute;inset:0;overflow:hidden}
417
+
418
+ /* the storyboard: a shot a page, frames left to right, the direction under them */
419
+ .shot .content{display:flex;flex-direction:column;gap:calc(var(--m-gap) * .8)}
420
+ .shot-head{display:flex;align-items:flex-end;gap:calc(var(--m-gap) * 1.2)}
421
+ .shot-id{display:flex;flex-direction:column;line-height:1}
422
+ .shot-id .tag{margin:0 0 .3em}
423
+ .shot-id .no{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-stat);line-height:.9;color:var(--accent);font-variant-numeric:tabular-nums}
424
+ .shot-title h2{margin:0}
425
+ .shot-title .tc{margin:.35em 0 0;font-size:var(--t-sm);color:var(--soft);font-variant-numeric:tabular-nums}
426
+ .vo{margin:0;font-size:var(--t-lead);line-height:1.45;color:var(--ink);max-width:92%;quotes:"\\201C" "\\201D"}
427
+ .vo::before{content:open-quote;color:var(--accent)}
428
+ .vo::after{content:close-quote;color:var(--accent)}
429
+ .vo.none{color:var(--soft);font-size:var(--t-sm)}
430
+ .vo.none::before,.vo.none::after{content:none}
431
+ /* one or two frames: the direction beside them; three or four: under them */
432
+ .shot-body{display:grid;gap:calc(var(--m-gap) * .8) var(--m-col-gap);align-items:start}
433
+ .shot-body.side.n1{grid-template-columns:minmax(0,1.1fr) minmax(0,1fr)}
434
+ .shot-body.side.n2{grid-template-columns:minmax(0,1.7fr) minmax(0,1fr)}
435
+ .shot-body.side .direction{grid-template-columns:minmax(0,1fr);border-top:0;padding-top:0;border-left:var(--card-stroke) solid var(--rule);padding-left:var(--m-col-gap)}
436
+ .frames{display:grid;gap:var(--m-col-gap);align-items:start}
437
+ .frames.n1{grid-template-columns:minmax(0,1fr)}
438
+ .frames.n2{grid-template-columns:repeat(2,minmax(0,1fr))}
439
+ .frames.n3{grid-template-columns:repeat(3,minmax(0,1fr))}
440
+ .frames.n4{grid-template-columns:repeat(4,minmax(0,1fr))}
441
+ .frame{margin:0;display:flex;flex-direction:column;gap:.5em}
442
+ .frame-art{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:calc(var(--m-radius) * .7 * var(--radius-k));background:var(--track);border:var(--card-stroke) solid var(--stroke)}
443
+ .frame-art>svg,.frame-art>img{position:absolute;inset:0;width:100%;height:100%;display:block}
444
+ .frame-art>img{object-fit:cover}
445
+ .frame figcaption{font-size:var(--t-note);line-height:1.4;color:var(--soft);font-variant-numeric:tabular-nums}
446
+ .frame figcaption b{color:var(--label);font-weight:600}
447
+ .frame figcaption span{display:block;color:var(--text);margin-top:.2em}
448
+ .frame .todo{position:absolute;inset:0;display:grid;place-items:center;border:2px dashed var(--rule);border-radius:inherit}
449
+ .frame .todo span{font-size:var(--t-sm);color:var(--soft)}
450
+ .direction{margin:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:calc(var(--m-gap) * .5) var(--m-col-gap);border-top:var(--card-stroke) solid var(--rule);padding-top:calc(var(--m-gap) * .6)}
451
+ .direction dt{font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-tag);letter-spacing:.14em;text-transform:var(--caps-label);color:var(--label);margin-bottom:.3em}
452
+ .direction dd{margin:0;font-size:var(--t-sm);line-height:1.45;color:var(--text)}
453
+ .sk .sk-p{fill:var(--ink)}
454
+ .sk .sk-app rect{fill:var(--paper);stroke:var(--stroke);stroke-width:5}
455
+ .sk .sk-app text{fill:var(--ink);font-family:var(--face-body);font-size:52px}
456
+ .sk .sk-s{fill:var(--accent)}
457
+ .sk .sk-arrow{fill:var(--soft)}
458
+ .sk .sk-label{fill:var(--text);font-family:var(--face-body);font-size:62px}
313
459
  `;
package/lib/engine.js CHANGED
@@ -109,7 +109,9 @@ function GATES({ min, chars, tokens, families, base }) {
109
109
  const lum = (h) => { const c = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
110
110
  const contrast = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
111
111
  const bgOf = (el) => { for (let e = el; e; e = e.parentElement) { const h = toHex(getComputedStyle(e).backgroundColor); if (h) return h; if (e.classList.contains("page")) break; } return null; };
112
- const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], dash: [], texts: [], notes: [] };
112
+ const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], dash: [], texts: [], notes: [], broken: [],
113
+ motion: { a: document.querySelectorAll("[data-a]").length, steps: document.querySelectorAll("[data-step]").length, video: document.querySelectorAll(".media.video").length, demo: document.querySelectorAll(".media.demo").length, placeholders: document.querySelectorAll(".frame.placeholder").length } };
114
+ document.querySelectorAll("img").forEach((im) => { if (im.getAttribute("src") && !(im.complete && im.naturalWidth)) res.broken.push(decodeURIComponent(im.getAttribute("src").replace(/^file:\/\//, "")).split("/").slice(-2).join("/")); });
113
115
  const seen = new Set();
114
116
  pages.forEach((pg, pi) => {
115
117
  const n = pi + 1;
@@ -176,16 +178,21 @@ function GATES({ min, chars, tokens, families, base }) {
176
178
  * Build a job: gates, and unless `outputs` is false, the PDF, the PNGs and the
177
179
  * sheet in `<job dir>/out/`. Returns { ok, rows, outputs, html, texts }.
178
180
  */
179
- export async function build(jobPath, { outputs: wantOutputs = true, outDir = null, quiet = false } = {}) {
181
+ export async function build(jobPath, { outputs: wantOutputs = true, outDir = null, quiet = false, brand: brandOverride = null } = {}) {
180
182
  const t0 = Date.now();
181
183
  const { job, file, dir } = await loadJob(jobPath);
182
- const brand = loadBrand(job.brand || DEFAULT_BRAND, job.theme || null);
184
+ // --brand builds the same job on the other brand, beside the first, in out-<brand>/
185
+ const brandName = brandOverride || job.brand || DEFAULT_BRAND;
186
+ const brand = loadBrand(brandName, job.theme || null);
183
187
  const surface = surfaceOf(job.surface || "letter");
184
188
  const content = loadContent(dir, job);
185
- const c = bind({ brand, surface, job, content });
189
+ delete job.__board;
190
+ const c = bind({ brand, surface, job, content, dir });
186
191
  const pages = job.pages(c);
187
192
  const html = assemble({ brand, surface, job, pages });
188
- const out = outDir || path.join(dir, "out");
193
+ const out = outDir || path.join(dir, brandOverride && brandOverride !== (job.brand || DEFAULT_BRAND) ? `out-${brandOverride}` : "out");
194
+ // every file a demo or a clip names must exist before anything is drawn from it
195
+ const absent = [...html.matchAll(/(?:data-demo-src|<video src)="(file:\/\/[^"]+)"/g)].map((m) => fileURLToPath(m[1])).filter((p) => !fs.existsSync(p));
189
196
  const work = path.join(out, "work");
190
197
  fs.mkdirSync(work, { recursive: true });
191
198
  const name = stem(job, file);
@@ -224,6 +231,16 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
224
231
  row(!deny.length, "names", job.denylist?.length ? (deny.length ? `found ${deny.join(", ")}` : `none of ${job.denylist.length} denied names`) : "no denylist", !job.denylist?.length);
225
232
  row(!g.contrast.length, "contrast", g.contrast.length ? `${g.contrast.length} low: ` + g.contrast.slice(0, 3).map((o) => `p${o.page} ${o.el} ${o.ratio}:1 (needs ${o.need})`).join("; ") : "every text passes WCAG AA", true);
226
233
  row(!g.measure.length, "measure", g.measure.length ? `${g.measure.length} long: ` + g.measure.slice(0, 3).map((o) => `p${o.page} ${o.el} ~${o.chars} chars`).join("; ") : `no line over ${surface.chars} characters`, true);
234
+ row(!g.broken.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}` : "every image loaded");
235
+ if (absent.length || g.motion.demo || g.motion.video) row(!absent.length, "media", absent.length ? `missing: ${absent.map((p) => path.basename(p)).join(", ")}` : `${g.motion.demo} demo${g.motion.demo === 1 ? "" : "s"}, ${g.motion.video} clip${g.motion.video === 1 ? "" : "s"}, each with its poster`);
236
+ const board = job.__board || null;
237
+ if (surface.kind === "slide" && !board) row(true, "motion", `${job.talk?.motion === "none" ? "still" : "entrances on every slide"}${g.motion.a ? `, ${g.motion.a} marked` : ""}${g.motion.steps ? `, ${g.motion.steps} held for steps` : ""} (the HTML; static exports show the end state)`);
238
+ if (board) {
239
+ const off = board.target ? Math.abs(board.total - board.target) / board.target : 0;
240
+ const mmss = (t) => `${Math.floor(t / 60)}:${String(Math.round(t % 60)).padStart(2, "0")}`;
241
+ row(off <= 0.1, "timing", `${board.shots.length} shots, ${mmss(board.total)}${board.target ? ` against ${mmss(board.target)} (${board.total > board.target ? "+" : "-"}${Math.round(off * 100)}%)` : ""}, scratch timing at ${board.wpm} wpm`, true);
242
+ row(!g.motion.placeholders, "frames", g.motion.placeholders ? `${g.motion.placeholders} frame${g.motion.placeholders === 1 ? "" : "s"} still to draw` : `${board.shots.reduce((a, s) => a + s.frames.length, 0)} frames, every one drawn`, true);
243
+ }
227
244
  row(!errors.length, "console", errors.length ? errors.slice(0, 2).join(" | ") : "no errors");
228
245
 
229
246
  const outputs = { html: htmlPath };
@@ -244,11 +261,20 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
244
261
  row(!(pc && pc.miss.length) && !dash, "pdf text", dash ? "em dash in the PDF text" : pc ? (pc.miss.length ? `missing lines ${pc.miss.join(", ")}` : `${pc.found}/${pc.total} lines read back from the PDF`) : "readable");
245
262
  }
246
263
  row(true, "renders", `${path.relative(process.cwd(), shots)}/p01…${String(els.length).padStart(2, "0")}.png, ${path.relative(process.cwd(), outputs.sheet)}`);
264
+ if (board) {
265
+ // the timeline for an editor and a captioner: one row a shot, one caption a line
266
+ const q = (v) => `"${String(v).replace(/"/g, '""')}"`;
267
+ const csv = ["shot,act,name,in,out,seconds,vo_in,vo_out,voiceover", ...board.shots.map((s) => [s.n, q(s.act), q(s.name), s.in, s.out, s.dur, s.voIn, s.voOut, q(s.vo)].join(","))].join("\n") + "\n";
268
+ fs.writeFileSync(outputs.csv = path.join(out, "timing.csv"), csv);
269
+ const srt = (t) => { const ms = Math.round(t * 1000), h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60, s = Math.floor(ms / 1000) % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(ms % 1000).padStart(3, "0")}`; };
270
+ const cues = board.shots.filter((s) => s.vo);
271
+ fs.writeFileSync(outputs.srt = path.join(out, "captions.srt"), cues.map((s, i) => `${i + 1}\n${srt(s.voIn)} --> ${srt(s.voOut)}\n${s.vo}\n`).join("\n"));
272
+ }
247
273
  }
248
274
  await page.close();
249
275
 
250
276
  const ok = rows.every((r) => r.ok || r.warn);
251
- const report = { ok, rows, outputs, pages: pageCount, brand: brand.name, surface: surface.name, ms: Date.now() - t0, texts: g.texts, notes: g.notes, name };
277
+ const report = { ok, rows, outputs, pages: pageCount, brand: brand.name, surface: surface.name, ms: Date.now() - t0, texts: g.texts, notes: g.notes, name, title: job.name || name, talk: job.talk || null, board };
252
278
  fs.writeFileSync(path.join(work, "report.json"), JSON.stringify({ ...report, texts: undefined }, null, 1));
253
279
  if (!quiet) printReport(report);
254
280
  return report;
@@ -6,17 +6,35 @@
6
6
  * runtime: on a slide or card surface, one page at a time scaled to the
7
7
  * window, arrow keys and clicks to move, G for the grid, print gives one
8
8
  * sheet per page; on a page surface, the pages stacked and scaled to fit.
9
+ *
10
+ * A slide deck gets the talk runtime instead (lib/talk.js): crossfades,
11
+ * entrances and steps, media, the presenter view. A storyboard gets the
12
+ * board runtime (lib/board.js): the shot pages to read, and a player that
13
+ * runs the frames against the clock. A demo named by `data-demo-src` travels
14
+ * gzipped inside the file and mounts in a frame when its slide comes up.
9
15
  */
10
16
  import fs from "node:fs";
11
17
  import path from "node:path";
18
+ import zlib from "node:zlib";
12
19
  import { fileURLToPath } from "node:url";
20
+ import { talkShell, TALK_CSS, TALK_JS } from "./talk.js";
21
+ import { BOARD_CSS, BOARD_JS, boardShell } from "./board.js";
13
22
 
14
23
  const MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
15
- ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2" };
24
+ ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2",
25
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", ogg: "audio/ogg" };
16
26
 
17
- export function singleFile(htmlPath, outPath, { kind = "page", w, h }) {
27
+ export function singleFile(htmlPath, outPath, { kind = "page", w, h, talk = null, board = null, title = "" }) {
18
28
  let html = fs.readFileSync(htmlPath, "utf8");
19
29
  const cache = new Map(), missing = [];
30
+ // demos first: each page travels gzipped, once, however many slides use it
31
+ const demos = new Map();
32
+ html = html.replace(/data-demo-src="(file:\/\/[^"]+)"/g, (m, u) => {
33
+ let p; try { p = fileURLToPath(u); } catch { return m; }
34
+ if (!fs.existsSync(p)) { missing.push(p); return m; }
35
+ if (!demos.has(p)) demos.set(p, { id: demos.size + 1, b64: zlib.gzipSync(fs.readFileSync(p)).toString("base64") });
36
+ return `data-demo="${demos.get(p).id}"`;
37
+ });
20
38
  html = html.replace(/file:\/\/[^"')\s]+/g, (u) => {
21
39
  if (cache.has(u)) return cache.get(u);
22
40
  let p; try { p = fileURLToPath(u); } catch { return u; }
@@ -25,8 +43,21 @@ export function singleFile(htmlPath, outPath, { kind = "page", w, h }) {
25
43
  const d = `data:${MIME[ext] || "application/octet-stream"};base64,${fs.readFileSync(p).toString("base64")}`;
26
44
  cache.set(u, d); return d;
27
45
  });
28
- html = html.replace("</body>", `${RUNTIME_CSS}<script>${RUNTIME_JS}</script></body>`)
29
- .replace("<body", `<body data-kind="${kind}" data-w="${w}" data-h="${h}"`);
46
+ const demoTags = [...demos.values()].map((d) => `<script type="application/octet-stream" id="wand-demo-${d.id}">${d.b64}</script>`).join("");
47
+ const data = (o) => `<script id="deck-data" type="application/json">${JSON.stringify(o).replace(/</g, "\\u003c")}</script>`;
48
+ if (board) {
49
+ const a = board.audio && fs.existsSync(board.audio) ? board.audio : "";
50
+ if (board.audio && !a) missing.push(board.audio);
51
+ const sh = boardShell({ ...board, audio: a ? `data:${MIME[path.extname(a).slice(1).toLowerCase()] || "audio/mpeg"};base64,${fs.readFileSync(a).toString("base64")}` : "" });
52
+ html = html.replace(/(<body[^>]*>)([\s\S]*)<\/body>/, (m, open, inner) => `${open}${sh.before}${inner}${sh.after}${BOARD_CSS}${data({ title, ...board, audio: !!a })}<script>${BOARD_JS}</script></body>`);
53
+ } else if (kind === "slide") {
54
+ const total = (html.match(/<section class="page /g) || []).length;
55
+ const sh = talkShell(total);
56
+ html = html.replace(/(<body[^>]*>)([\s\S]*)<\/body>/, (m, open, inner) => `${open}${sh.before}${inner}${sh.after}${TALK_CSS}${data({ title, motion: "auto", minutes: 0, ...(talk || {}) })}${demoTags}<script>${TALK_JS}</script></body>`);
57
+ } else {
58
+ html = html.replace("</body>", `${RUNTIME_CSS}<script>${RUNTIME_JS}</script></body>`);
59
+ }
60
+ html = html.replace("<body", `<body data-kind="${kind}" data-w="${w}" data-h="${h}"`);
30
61
  fs.writeFileSync(outPath, html);
31
62
  return { out: outPath, missing, bytes: fs.statSync(outPath).size };
32
63
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * lib/runtime_check.js — the one-file HTML, driven the way a presenter drives it.
3
+ *
4
+ * The gates in engine.js read the static render, which is all the PDF, the
5
+ * PNGs and the .pptx ever see. A talk or a board also has a runtime, and a
6
+ * runtime can break where no still frame shows it. This opens the exported
7
+ * file in Chromium and uses it: every slide and step by keyboard, the
8
+ * overview, the notes, the presenter view kept in step both ways, every demo
9
+ * mounted; or, for a board, play, scrub and the frames on their cues.
10
+ * Ported from the BofA talk's qa.mjs feature checks.
11
+ */
12
+ import { pathToFileURL } from "node:url";
13
+ import { browser } from "./engine.js";
14
+
15
+ const watch = (page, errors) => {
16
+ page.on("pageerror", (e) => errors.push(String(e).split("\n")[0]));
17
+ page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
18
+ };
19
+
20
+ export async function checkTalk(file, { total }) {
21
+ const b = await browser();
22
+ const ctx = await b.newContext({ viewport: { width: 1440, height: 810 } });
23
+ const page = await ctx.newPage();
24
+ const errors = [], fail = [];
25
+ watch(page, errors);
26
+ try {
27
+ await page.goto(pathToFileURL(file).href, { waitUntil: "load" });
28
+ await page.waitForFunction(() => window.wandTalk, null, { timeout: 8000 });
29
+ const st = (p = page) => p.evaluate(() => ({ cur: wandTalk.cur, shown: wandTalk.shown, total: wandTalk.total, on: document.querySelectorAll(".stage > .page.is-current").length }));
30
+ const s0 = await st();
31
+ if (s0.total !== total) fail.push(`the runtime sees ${s0.total} slides, the build ${total}`);
32
+ if (s0.on !== 1) fail.push(`${s0.on} slides on screen at once`);
33
+
34
+ // every slide and every step, forward, by keyboard
35
+ let presses = 0, steps = 0;
36
+ for (;;) {
37
+ const a = await st(); await page.keyboard.press("ArrowRight"); const z = await st();
38
+ if (z.cur === a.cur && z.shown === a.shown) break;
39
+ presses++; if (z.cur === a.cur) steps++;
40
+ if (presses > 600) { fail.push("ArrowRight never reaches the end"); break; }
41
+ }
42
+ if ((await st()).cur !== total - 1) fail.push("the keyboard does not reach the last slide");
43
+ await page.keyboard.press("Home");
44
+
45
+ await page.keyboard.press("g");
46
+ const thumbs = await page.$$eval(".overview:not([hidden]) .thumb", (t) => t.length);
47
+ if (thumbs !== total) fail.push(`the overview shows ${thumbs} of ${total}`);
48
+ await page.keyboard.press("Escape");
49
+ await page.keyboard.press("n");
50
+ if (!(await page.$eval(".notes-panel", (e) => !e.hidden))) fail.push("N does not open the notes");
51
+ await page.keyboard.press("Escape");
52
+
53
+ // the presenter's window, kept in step from either side
54
+ const [pop] = await Promise.all([ctx.waitForEvent("page", { timeout: 8000 }), page.keyboard.press("p")]);
55
+ watch(pop, errors);
56
+ await pop.waitForLoadState("load");
57
+ await pop.waitForFunction(() => window.wandTalk, null, { timeout: 8000 });
58
+ await page.keyboard.press("ArrowRight"); await page.waitForTimeout(250);
59
+ let r = await st(), q = await st(pop);
60
+ if (r.cur !== q.cur || r.shown !== q.shown) fail.push(`room ${r.cur + 1}/${r.shown} and presenter ${q.cur + 1}/${q.shown} disagree`);
61
+ await pop.keyboard.press("ArrowRight"); await page.waitForTimeout(250);
62
+ r = await st(); q = await st(pop);
63
+ if (r.cur !== q.cur || r.shown !== q.shown) fail.push("the presenter's keys do not move the room");
64
+ await pop.close();
65
+
66
+ // every demo mounts and loads
67
+ const demoSlides = await page.$$eval(".stage > .page", (ps) => ps.map((p, i) => (p.querySelector(".media.demo") ? i : -1)).filter((i) => i >= 0));
68
+ for (const i of demoSlides) {
69
+ await page.evaluate((n) => wandTalk.go(n), i);
70
+ try { await page.waitForSelector(".stage > .page.is-current .media.demo.is-ready", { timeout: 10000 }); }
71
+ catch { fail.push(`the demo on slide ${i + 1} never loaded`); }
72
+ }
73
+ const facts = `${total} slides${steps ? ` and ${steps} steps` : ""} by keyboard, overview, notes, presenter in step both ways${demoSlides.length ? `, ${demoSlides.length} demo${demoSlides.length === 1 ? "" : "s"} mounted` : ""}`;
74
+ const all = [...fail, ...errors.map((e) => `console: ${e}`)];
75
+ return { ok: !all.length, msg: all.length ? all.slice(0, 3).join("; ") : facts };
76
+ } catch (e) {
77
+ return { ok: false, msg: `${String(e.message || e).split("\n")[0]}` };
78
+ } finally { await ctx.close(); }
79
+ }
80
+
81
+ export async function checkBoard(file, { shots, total }) {
82
+ const b = await browser();
83
+ const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
84
+ const page = await ctx.newPage();
85
+ const errors = [], fail = [];
86
+ watch(page, errors);
87
+ try {
88
+ await page.goto(pathToFileURL(file).href, { waitUntil: "load" });
89
+ await page.waitForFunction(() => window.wandBoard, null, { timeout: 8000 });
90
+ await page.click('[data-ui="play-board"]');
91
+ await page.waitForTimeout(1200);
92
+ const a = await page.evaluate(() => ({ t: wandBoard.t, playing: wandBoard.playing, on: document.querySelectorAll(".pl-frames .pf.on").length, art: !!document.querySelector(".pl-frames .pf.on .frame-art") }));
93
+ if (!a.playing || a.t < 0.6) fail.push(`the player did not run (t=${a.t.toFixed(1)})`);
94
+ if (a.on !== 1 || !a.art) fail.push("no frame on the player's screen");
95
+ // scrub to the last shot: its frame and its words come up
96
+ const last = shots[shots.length - 1];
97
+ await page.evaluate((t) => { wandBoard.pause(); wandBoard.seek(t); }, last.in + 0.05);
98
+ const z = await page.evaluate(() => ({ id: document.querySelector(".pl-frames .pf.on")?.dataset.id, vo: document.querySelector(".pl-vo").dataset.n }));
99
+ if (z.vo !== String(last.n)) fail.push(`seeking to shot ${last.n} shows shot ${z.vo}`);
100
+ if (!z.id || !z.id.startsWith(String(last.n).padStart(2, "0"))) fail.push(`seeking to shot ${last.n} shows frame ${z.id}`);
101
+ await page.keyboard.press("Escape");
102
+ const all = [...fail, ...errors.map((e) => `console: ${e}`)];
103
+ const frames = shots.reduce((s, x) => s + x.frames.length, 0);
104
+ return { ok: !all.length, msg: all.length ? all.slice(0, 3).join("; ") : `the player runs ${frames} frames over ${Math.floor(total / 60)}:${String(Math.round(total % 60)).padStart(2, "0")}, scrubs to the last shot, captions follow` };
105
+ } catch (e) {
106
+ return { ok: false, msg: `${String(e.message || e).split("\n")[0]}` };
107
+ } finally { await ctx.close(); }
108
+ }