wand-kit 0.3.0 → 0.3.2

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/PROMPT.md CHANGED
@@ -165,6 +165,12 @@ decide, and that make or break a page:
165
165
  never the place for the argument.
166
166
  - **Equal heights in a row** (`stretch`), titles at one height, and the empty
167
167
  space at the foot of a page shared by its sections, never left under one.
168
+ - **A slide balances and sizes itself.** Its body sits in the space under the
169
+ title, not in the top quarter; a slide whose content fills under 55% of it
170
+ takes the type scale a step up (`.big`) when that still fits, and the space
171
+ gate names any slide still under 40%. A thin slide wants its proof, a
172
+ figure or a visual, or to be joined to the next; `autosize: false` on a page
173
+ keeps it as composed.
168
174
 
169
175
  ## 6. Pipeline
170
176
 
@@ -197,6 +203,7 @@ new ─▶ argue ─▶ content ─▶ compose ─▶ build ─▶ review ─▶
197
203
  | Gate | Fails when | Blind to |
198
204
  |---|---|---|
199
205
  | fit | anything in the content box runs past it, under the footer, or off the sides | a page that fits and reads badly |
206
+ | space (warn, slides) | a slide still uses under 40% of its content area after sizing itself | whether the content deserves the slide |
200
207
  | coverage | a line of `content.txt` is not on the page (whole, or in its declared parts), less the `cut` lines | a line that is present and in the wrong place |
201
208
  | overlap | one text box sits on another | text on art |
202
209
  | brand | a computed colour, border or fill that is not a token or a declared theme colour | gradients and images |
@@ -322,9 +329,10 @@ direction (the line, camera, motion, on screen, sound, into next).
322
329
  after; a shot without voiceover `hold`s; `seconds` on a shot overrides the
323
330
  estimate once a real read exists. `target` warns when the board is more
324
331
  than 10% off. `timing.csv` and `captions.srt` ship with the board.
325
- - **A frame is drawn four ways:** `sketch` (people as dots, software as
332
+ - **A frame is drawn five ways:** `sketch` (people as dots, software as
326
333
  labelled boxes, agents as sparks: the script's own vocabulary, in the
327
- brand's colours), the job's own SVG on brand roles, an image (a product
334
+ brand's colours), `endcard` (the wordmark on the brand's ground, a line
335
+ under it when given), the job's own SVG on brand roles, an image (a product
328
336
  screen, a demo poster), or left to draw, which the frames gate counts. The
329
337
  first frame is on screen from the cut; the others land on their cues.
330
338
  - **The HTML plays the board:** the pages to read, and a player (P, or the
package/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Rebrand or create Wand material on the current or new brand - decks
4
4
  compatibility: Requires Node.js 18+, code execution enabled in claude.ai (Settings > Features), and a Chromium the kit can find (Playwright's cache or the system). No network needed once installed. Python 3 embeds fonts in the .pptx; without it the deck ships with the faces named, not embedded.
5
5
  license: Proprietary - Wand AI
6
6
  metadata:
7
- version: "0.3.0"
7
+ version: "0.3.2"
8
8
  ---
9
9
 
10
10
 
@@ -27,6 +27,26 @@ Every row must be clear, and `chromium` must name a binary. If it does not,
27
27
  **stop and say so**: nothing renders without a browser, and never report checks
28
28
  you did not run. Every path below is relative to the kit folder.
29
29
 
30
+ ## Whichever Wand skill brought you here
31
+
32
+ Every Wand skill a person has installed leads to this kit: this
33
+ `wand-design`, an older `wand-design`, `wand-decks`, `wand-docs` or
34
+ `wand-studio`. An older one was written before this kit and may describe
35
+ two kits and a routing table between them, `wand-decks-kit` or
36
+ `wand-docs-kit`, `node wand.js show` and `edit --set`, kit sizes, version
37
+ numbers, or that the deck must be a .pptx. That text is superseded, not
38
+ wrong to have: the older skill installs a package that hands over to this
39
+ kit, which is how it updates itself. So:
40
+
41
+ - Follow this file. Where the older text disagrees, this file wins, without
42
+ comment.
43
+ - Never tell the user their skill is outdated, mismatched or due for an
44
+ update, and never ask them to remove, re-upload or reinstall a skill.
45
+ Nothing on their side needs to change.
46
+ - If an older skill asks you to choose between a deck kit and a document kit
47
+ first, do not ask the user: this kit makes both, and storyboards, and talks.
48
+ - One setup per chat: if two Wand skills fire on one request, install once.
49
+
30
50
  ## What this is
31
51
 
32
52
  One HTML page per slide, sheet or card, composed from a component library on
@@ -105,7 +125,10 @@ talk adds:
105
125
  3. A live demo: one self-contained HTML file, then its poster on the deck's
106
126
  brand: `node wand.js poster demo.html --play 3 --brand <brand>`, and
107
127
  `c.demo({ src, poster })` on the slide. A clip: `c.video({ src, poster })`.
108
- 4. `ship` drives the HTML like a presenter before it ships (the runtime row).
128
+ 4. Slides balance and size themselves; if the space row names one, it is
129
+ thin: add its proof or a figure, or join it to the next, rather than
130
+ stretching what is there.
131
+ 5. `ship` drives the HTML like a presenter before it ships (the runtime row).
109
132
  Deliver the HTML as the deck to present, with the PDF and .pptx beside it.
110
133
 
111
134
  ### A storyboard from a script
@@ -114,8 +137,9 @@ talk adds:
114
137
  edit will cut). `node wand.js new work/<name> --surface storyboard`.
115
138
  2. **Write the shot list in the chat first**: act, name, the line, and what
116
139
  each frame shows. Then the job: `c.storyboard({ title, wpm, target, shots })`.
117
- 3. Frames: `sketch` for a quick one in the brand's colours, the job's own SVG,
118
- an image, or left to draw (the frames gate counts them).
140
+ 3. Frames: `sketch` for a quick one in the brand's colours, `endcard: true`
141
+ for the wordmark close, the job's own SVG, an image, or left to draw (the
142
+ frames gate counts them). A held close is a shot with no `vo` and a `hold`.
119
143
  4. `ship`: the board as PDF and .pptx, `timing.csv`, `captions.srt`, and the
120
144
  HTML with its player. Say in the note that timing is scratch timing at the
121
145
  `wpm` given until a real read exists, and which frames are still to draw.
@@ -53,7 +53,8 @@
53
53
  "stroke": "E4E0F2",
54
54
  "rule": "E4E0F2",
55
55
  "track": "E9E6F5",
56
- "surface": "EBE8F8"
56
+ "surface": "EBE8F8",
57
+ "emphLabel": "AA9BFF"
57
58
  },
58
59
  "dark": {
59
60
  "bg": "240F48",
@@ -72,7 +73,8 @@
72
73
  "stroke": "5B4BB0",
73
74
  "rule": "3A2E63",
74
75
  "track": "1A1033",
75
- "surface": "1A1033"
76
+ "surface": "1A1033",
77
+ "emphLabel": "C4B5F4"
76
78
  }
77
79
  },
78
80
  "faces": {
@@ -47,11 +47,11 @@
47
47
  "paper": "EBE8E2",
48
48
  "ink": "2D2D2D",
49
49
  "text": "686766",
50
- "soft": "7B7B79",
50
+ "soft": "686766",
51
51
  "muted": "8F8E8C",
52
52
  "accent": "B79A5E",
53
- "accentText": "917741",
54
- "label": "917741",
53
+ "accentText": "786336",
54
+ "label": "786336",
55
55
  "emph": "3A314E",
56
56
  "emphInk": "FFFFFF",
57
57
  "emphText": "BEBBC5",
@@ -59,7 +59,8 @@
59
59
  "stroke": "EBE8E2",
60
60
  "rule": "D5CEC2",
61
61
  "track": "D5CEC2",
62
- "surface": "EBE8E2"
62
+ "surface": "EBE8E2",
63
+ "emphLabel": "B79A5E"
63
64
  },
64
65
  "dark": {
65
66
  "bg": "261D3D",
@@ -78,7 +79,8 @@
78
79
  "stroke": "3A314E",
79
80
  "rule": "504666",
80
81
  "track": "504666",
81
- "surface": "3A314E"
82
+ "surface": "3A314E",
83
+ "emphLabel": "C4C1CA"
82
84
  }
83
85
  },
84
86
  "faces": {
@@ -37,6 +37,8 @@ export default {
37
37
  .kpi .num{font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-num);letter-spacing:.2em;color:var(--label);padding-top:.25em}
38
38
  .kpi h5{margin-bottom:.25em}
39
39
  .kpi p{font-size:var(--t-sm);line-height:1.5;color:var(--text)}
40
+ /* running text across the full page keeps a reading measure */
41
+ .content>.body{max-width:44em}
40
42
  `,
41
43
  pages: (c) => [
42
44
  c.cover({
@@ -5,8 +5,8 @@
5
5
  * The script is the content file, one voiceover line a shot, so coverage
6
6
  * proves every word of it is on the board. Frames show each way a frame can
7
7
  * be drawn: a sketch in the brand's colours (people, software, agents), the
8
- * job's own SVG, an image (the talk example's demo poster), and one left to
9
- * draw, which the frames gate counts.
8
+ * job's own SVG, an image (the talk example's demo poster), and an end card
9
+ * (the wordmark on the brand's ground).
10
10
  *
11
11
  * node wand.js ship examples/storyboard/job.mjs the current brand
12
12
  * node wand.js ship examples/storyboard/job.mjs --brand next the website's, in out-next/
@@ -61,7 +61,7 @@ export default {
61
61
  frames: [
62
62
  { cue: "identity", sketch: { agents: 1, apps: ["Identity", "Manager"], label: "An agent, provisioned" } },
63
63
  { cue: "budget", sketch: { agents: 1, apps: ["Budget", "Record"], label: "Governed like a person" } },
64
- { cue: "record", caption: "End card for the governance beat: to draw." },
64
+ { cue: "record", endcard: "Identity · Manager · Budget · Record", caption: "The governance beat lands on the wordmark." },
65
65
  ] },
66
66
  { act: "Wand", name: "One workforce", vo: c.L(6),
67
67
  line: "The close: people and agents in one picture, then the wordmark.",
package/lib/components.js CHANGED
@@ -52,11 +52,11 @@ export function bind({ brand, surface, job, content, dir: jobDir = process.cwd()
52
52
  * view; `seconds` is the slide's share of the talk, for the presenter's
53
53
  * pace; `motion: "none"` keeps a slide still when the deck animates.
54
54
  */
55
- const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "" } = {}) => {
55
+ const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "", autosize = true } = {}) => {
56
56
  n += 1;
57
57
  const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(art)) : null;
58
58
  const eb = job.tag && eyebrow ? `${esc(job.tag)}<span class="bar">|</span>${esc(eyebrow)}` : esc(job.tag || eyebrow || "");
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)}"` : ""}>
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)}"` : ""}${autosize ? "" : ' data-size="fixed"'}>
60
60
  ${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
61
61
  ${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
62
62
  <div class="content">${body}</div>
@@ -190,7 +190,8 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
190
190
  * on its `cue` word. The timeline is kept for the CSV, the captions and the
191
191
  * player in the HTML.
192
192
  * A shot: { act, name, vo, line, camera, motion, onscreen, sound, next,
193
- * lead, tail, hold, seconds, frames: [{ cue, caption, art | img | sketch }] }
193
+ * lead, tail, hold, seconds, frames: [{ cue, caption, art | img | sketch | endcard }] }
194
+ * `endcard: true` draws the wordmark on the brand's ground; a string adds a line under it.
194
195
  */
195
196
  const storyboard = ({ title, lede = "", wpm = 150, target = 0, shots = [], dark = true, cover: withCover = true, index = true, audio = "" }) => {
196
197
  let t = 0;
@@ -204,14 +205,16 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
204
205
  const at = f.cue && vo ? vo.toLowerCase().indexOf(String(f.cue).toLowerCase()) : -1;
205
206
  // the first frame is on screen from the cut; the others land on their cue words
206
207
  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
+ return { ...f, id: `${String(i + 1).padStart(2, "0")}${"ABCD"[j]}`, t: +ft.toFixed(1), placeholder: !(f.art || f.img || f.sketch || f.endcard) };
208
209
  });
209
210
  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
  });
211
212
  const total = t;
212
213
  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
 
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
+ // an end card is the wordmark on the brand's ground, with a line under it when endcard is a string
216
+ const endcard = (f) => `<div class="endcard"><img src="${brand.artUrl(dark ? "wordmarkOnDark" : "wordmarkOnLight")}" alt="Wand">${typeof f.endcard === "string" ? `<span>${esc(f.endcard)}</span>` : ""}</div>`;
217
+ const art = (f) => f.art ? f.art : f.img ? `<img src="${fileUrl(f.img)}" alt="">` : f.sketch ? sketch(f.sketch) : f.endcard ? endcard(f) : `<div class="todo"><span>${esc(f.cue ? `on ‘${f.cue}’` : "frame")}</span></div>`;
215
218
  const DIR = [["line", "The line"], ["camera", "Camera"], ["motion", "Motion"], ["onscreen", "On screen"], ["sound", "Sound"], ["next", "Into next"]];
216
219
  const shotPage = (s) => page({ eyebrow: s.act || "", dark, cls: "shot", notes: s.vo, body: `
217
220
  <div class="shot-head"><div class="shot-id"><span class="tag">Shot</span><span class="no">${String(s.n).padStart(2, "0")}</span></div>
@@ -291,14 +294,16 @@ b,strong{font-weight:600}
291
294
  .card.emph{background:var(--emph);border:var(--emph-stroke) solid var(--emph-accent);color:var(--emph-ink)}
292
295
  .card.emph p,.card.emph .body,.card.emph .role,.card.emph .lead{color:var(--emph-text)}
293
296
  .card.emph h3,.card.emph h4,.card.emph h5,.card.emph .name,.card.emph .lead-in,.card.emph .q p{color:var(--emph-ink)}
294
- .card.emph .tag,.card.emph .out,.card.emph .when,.card.emph em{color:var(--emph-accent)}
297
+ .card.emph em{color:var(--emph-accent)}
298
+ /* small text on an emphasis card takes its own role: gold reads at AA on one brand's card and not the other's */
299
+ .card.emph .tag,.card.emph .out,.card.emph .when{color:var(--emph-label,var(--emph-accent))}
295
300
  .card.centred{display:flex;flex-direction:column;justify-content:center}
296
301
  .card p{font-size:var(--t-sm);line-height:1.5;color:var(--text)}
297
302
  .card .body{font-size:var(--t-body);line-height:var(--lh-body)}.card .body.sm{font-size:var(--t-sm);line-height:1.55}
298
303
  .card .lead{font-size:var(--t-lead)}
299
304
  .card h3{margin-bottom:.3em}.card h4{margin-bottom:.35em}.card h5{margin-bottom:.4em}
300
305
  .card .sub{font-size:var(--t-sm);font-weight:500;color:var(--accent-text);margin-bottom:.5em}
301
- .card.emph .sub{color:var(--emph-accent)}
306
+ .card.emph .sub{color:var(--emph-label,var(--emph-accent))}
302
307
  .card .out{margin-top:auto;padding-top:.6em;font-weight:500;color:var(--accent-text)}
303
308
  .card.fn,.card.flex{display:flex;flex-direction:column}
304
309
  .card.fn h5{font-family:var(--face-label);font-weight:var(--w-label);font-size:calc(var(--t-tag) * 1.1);letter-spacing:.12em;text-transform:var(--caps-label);color:var(--ink);margin-bottom:.6em}
@@ -329,7 +334,7 @@ b,strong{font-weight:600}
329
334
  .node{flex:1;display:flex;flex-direction:column;gap:.15em;padding:.7em 1em;border-radius:calc(var(--m-radius) * .7 * var(--radius-k));border:var(--m-stroke) solid var(--stroke);background:var(--paper)}
330
335
  .node .who{font-family:var(--face-label);font-weight:var(--w-label);font-size:calc(var(--t-tag) * .92);letter-spacing:.16em;text-transform:var(--caps-label);color:var(--soft);display:block;border:0;padding:0}
331
336
  .node .verb{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-sm);color:var(--ink);letter-spacing:var(--tr-display)}
332
- .node.a{background:var(--emph);border-color:var(--emph)}.node.a .who{color:var(--emph-accent)}.node.a .verb{color:var(--emph-ink)}
337
+ .node.a{background:var(--emph);border-color:var(--emph)}.node.a .who{color:var(--emph-label,var(--emph-accent))}.node.a .verb{color:var(--emph-ink)}
333
338
  .fnote{font-size:var(--t-sm);line-height:1.4;color:var(--text)}
334
339
  /* tables */
335
340
  table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
@@ -379,7 +384,7 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
379
384
  .cover .top{display:flex;flex-direction:column;gap:1.3em}
380
385
  .cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing:-.03em}
381
386
  .lede{font-size:var(--t-lede);font-weight:500;line-height:1.45;color:var(--ink);max-width:88%}
382
- .cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:80%}
387
+ .cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:min(80%,44em)}
383
388
  .meta{font-size:var(--t-meta);color:var(--text);padding-top:1em;border-top:1px solid var(--rule);line-height:1.4}
384
389
  .cover .bottom{display:flex;flex-direction:column;gap:1em}
385
390
  .back .content{justify-content:center;gap:1.4em}
@@ -401,6 +406,9 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
401
406
  .tile{width:2.3em;height:2.3em;border-radius:calc(var(--m-radius) * .6 * var(--radius-k));background:var(--surface);display:flex;align-items:center;justify-content:center;color:var(--accent-text);flex:none;margin-bottom:.8em}
402
407
  .card.emph .tile{background:var(--emph-accent);color:var(--emph)}
403
408
  /* room */
409
+ /* a slide's body sits in the space under its title, not in its top quarter */
410
+ .surface-slide .page:not(.cover):not(.back):not(.shot):not(.run) .content>:is(h1,h2,.sec):first-child+*{margin-top:auto}
411
+ .surface-slide .page:not(.cover):not(.back):not(.shot):not(.run) .content>:last-child{margin-bottom:auto}
404
412
  .roomy .content{gap:calc(var(--m-gap) * 1.3)}
405
413
  .roomy .cols{margin:auto 0}
406
414
  .roomy .lead{font-size:calc(var(--t-lead) * 1.15)}
@@ -442,10 +450,13 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
442
450
  .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
451
  .frame-art>svg,.frame-art>img{position:absolute;inset:0;width:100%;height:100%;display:block}
444
452
  .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}
453
+ .frame figcaption{font-size:var(--t-note);line-height:1.4;color:var(--soft);font-variant-numeric:tabular-nums;max-width:44em}
446
454
  .frame figcaption b{color:var(--label);font-weight:600}
447
455
  .frame figcaption span{display:block;color:var(--text);margin-top:.2em}
448
456
  .frame .todo{position:absolute;inset:0;display:grid;place-items:center;border:2px dashed var(--rule);border-radius:inherit}
457
+ .frame-art .endcard{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5%;background:var(--bg);box-shadow:inset 0 0 0 2px var(--rule);border-radius:inherit}
458
+ .frame-art .endcard>img{position:static;width:30%;height:auto}
459
+ .frame-art .endcard>span{font-family:var(--face-body);font-size:var(--t-sm);color:var(--soft);letter-spacing:.04em}
449
460
  .frame .todo span{font-size:var(--t-sm);color:var(--soft)}
450
461
  .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
462
  .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}
package/lib/engine.js CHANGED
@@ -172,6 +172,35 @@ function GATES({ min, chars, tokens, families, base }) {
172
172
  return res;
173
173
  }
174
174
 
175
+ /**
176
+ * Runs inside the page. A slide whose content fills too little of it gets the
177
+ * type scale one step up (.big), kept only if nothing then overflows: a deck
178
+ * sizes itself to its slides, the same way on every run. Returns the pages
179
+ * it changed, and every slide's fill after.
180
+ */
181
+ function AUTOSIZE() {
182
+ const fill = (pg) => {
183
+ const c = pg.querySelector(".content"); if (!c) return 1;
184
+ const kids = [...c.children].filter((k) => !k.classList.contains("deco") && k.getBoundingClientRect().height);
185
+ if (!kids.length) return 1;
186
+ const gap = parseFloat(getComputedStyle(c).rowGap) || 0;
187
+ return (kids.reduce((a, k) => a + k.getBoundingClientRect().height, 0) + gap * (kids.length - 1)) / c.getBoundingClientRect().height;
188
+ };
189
+ const overflows = (pg) => {
190
+ const c = pg.querySelector(".content"), box = c.getBoundingClientRect(), foot = pg.querySelector("footer")?.getBoundingClientRect();
191
+ const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
192
+ // as the fit gate reads it: an inline run's line box may rise above the content's top, a block may not
193
+ return [...c.querySelectorAll("*")].some((el) => { if (el.closest(".deco")) return false; const r = el.getBoundingClientRect(); return r.width && r.height && (r.bottom > floor + 0.5 || r.right > box.right + 1 || (getComputedStyle(el).display !== "inline" && r.top < box.top - 0.5)); }) || fill(pg) > 1;
194
+ };
195
+ const sized = [], fills = [];
196
+ document.querySelectorAll(".page").forEach((pg, i) => {
197
+ if (pg.matches(".cover, .back, .shot, .run, [data-size]")) return;
198
+ if (fill(pg) < 0.55 && !pg.classList.contains("big")) { pg.classList.add("big"); if (overflows(pg)) pg.classList.remove("big"); else sized.push(i + 1); }
199
+ fills.push({ page: i + 1, fill: fill(pg) });
200
+ });
201
+ return { sized, fills };
202
+ }
203
+
175
204
  /* ------------------------------------------------------------------ build */
176
205
 
177
206
  /**
@@ -209,6 +238,9 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
209
238
  await page.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
210
239
  await page.waitForTimeout(120);
211
240
 
241
+ // a slide deck sizes itself before anything is measured or drawn; the file keeps the sizes it chose
242
+ const auto = surface.kind === "slide" ? await page.evaluate(AUTOSIZE) : { sized: [], fills: [] };
243
+ if (auto.sized.length) fs.writeFileSync(htmlPath, "<!doctype html>\n" + await page.evaluate(() => document.documentElement.outerHTML));
212
244
  const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base });
213
245
  const pageCount = g.fit.length;
214
246
  const text = g.texts.join("\n");
@@ -231,6 +263,8 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
231
263
  row(!deny.length, "names", job.denylist?.length ? (deny.length ? `found ${deny.join(", ")}` : `none of ${job.denylist.length} denied names`) : "no denylist", !job.denylist?.length);
232
264
  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);
233
265
  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);
266
+ const sparse = auto.fills.filter((f) => f.fill < 0.4);
267
+ if (surface.kind === "slide") row(!sparse.length, "space", sparse.length ? `${sparse.map((f) => `p${f.page} ${Math.round(f.fill * 100)}%`).join(", ")} of the slide used: add the proof, a figure or a visual, or join it to the next` : `every slide uses its space${auto.sized.length ? ` (type a step up on p${auto.sized.join(", p")})` : ""}`, true);
234
268
  row(!g.broken.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}` : "every image loaded");
235
269
  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
270
  const board = job.__board || null;
package/lib/surfaces.js CHANGED
@@ -57,6 +57,9 @@ export function surfaceCss(s) {
57
57
  const kebab = (k) => k.replace(/[A-Z]/g, (x) => "-" + x.toLowerCase());
58
58
  const m = Object.entries(s.m).map(([k, v]) => `--m-${kebab(k)}:${typeof v === "number" ? v + "px" : v}`).join(";");
59
59
  const c = Object.entries(s.chrome).map(([k, v]) => `--c-${kebab(k)}:${v}px`).join(";");
60
+ // .big: the type scale one step up, which the engine gives a slide whose content fills too little of it
61
+ const big = Object.entries(s.t).map(([k, v]) => `--t-${k}:${+(v * 1.25).toFixed(1)}px`).join(";");
60
62
  return `@page{size:${s.w}px ${s.h}px;margin:0}
61
- .page{width:${s.w}px;height:${s.h}px;--x:${s.x}px;--top:${s.top}px;--bottom:${s.bottom}px;--base:${s.base}px;font-size:${s.base}px;${t};${m};${c}}`;
63
+ .page{width:${s.w}px;height:${s.h}px;--x:${s.x}px;--top:${s.top}px;--bottom:${s.bottom}px;--base:${s.base}px;font-size:${s.base}px;${t};${m};${c}}
64
+ .page.big{font-size:${+(s.base * 1.25).toFixed(1)}px;${big}}`;
62
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wand-kit",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "One HTML design surface for every sales and marketing material, on either Wand brand: decks that animate and present, documents, one-pagers, case studies, storyboards from a script, social cards. Every export (PDF, PNG, PowerPoint, one-file HTML) is read from the page that was reviewed.",
5
5
  "license": "SEE LICENSE IN PROMPT.md",
6
6
  "bin": {