wand-kit 0.3.2 → 0.3.3

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
@@ -80,13 +80,24 @@ the ground's values, so one component draws on both.
80
80
  | `label` | 6C5CE7 | A78BFA | tags, section numbers |
81
81
  | `emph` / `emphInk` / `emphText` / `emphAccent` | 251C63 / FFF / DCD6F5 / AA9BFF | 4D2E8C / FFF / DCD6F5 / C4B5F4 | the emphasis card and what sits on it |
82
82
  | `stroke` / `rule` / `track` / `surface` | E4E0F2 / E4E0F2 / E9E6F5 / EBE8F8 | 5B4BB0 / 3A2E63 / 1A1033 / 1A1033 | card strokes, hairlines, meter tracks, tiles |
83
+ | `second` / `secondSm` | 6C5CE7 / 6C5CE7 | 9481FF / 9481FF | a title's phrase, large / small (on this brand, the accent itself) |
84
+ | `gradeLow` / `gradeHigh` | 240F48 / F8F6FB | the same | the photograph grade: where blacks lift to, where highlights settle |
83
85
 
84
86
  The next brand fills the same roles from the website (gold the one accent,
85
- violets as grounds, beige as paper); see `brand-next/tokens.json`.
87
+ violets as grounds, beige as paper); see `brand-next/tokens.json`. Its
88
+ `second` is a quiet tone, not gold (7B7B79 on light, 9D99A8 on dark, large
89
+ type only; `secondSm`, the text grey, below that size).
86
90
 
87
91
  **Rules.**
88
92
  - One accent. The accent phrase in a title is the single most recognisable
89
93
  device in the system: one per title, never two, the sentence period in ink.
94
+ - **On the next brand a title has two tones and gold is rare.** The phrase
95
+ takes `second`, a quieter tone of the ink; gold stays for the pages that
96
+ carry the brand's art (the cover, a statement, the close), for figures and
97
+ for the spark. The website's own headings carry no gold at all, and the
98
+ wand.ai rebuild (October 2026) read as a template while every heading's
99
+ second line was gold: when gold is everywhere it means nothing. On the
100
+ current brand `second` is the accent, so nothing changes there.
90
101
  - One emphasis card per row, at most one or two per page.
91
102
  - Light pages carry no art. Dark pages carry art on the cover, the closing and
92
103
  a statement; a dark content page is flat.
@@ -142,6 +153,7 @@ The components (`node wand.js components`; `kit/lib/components.js`):
142
153
  | `quote({ text, by, emph })`, `who` | testimonial cards, name over role, no photographs |
143
154
  | `stats`, `delta(from, to)` | figures, before-and-after |
144
155
  | `steps(items)` | next steps with arrows |
156
+ | `photo(name, { pos })` | a brand photograph filling its box, with the brand's grade |
145
157
  | `callout`, `list`, `chapter`, `img`, `icon`, `tile` | the rest |
146
158
  | `enter(kind, i)`, `step(n, html)`, `countup(text)`, `stat({ count })` | motion in a talk (§12) |
147
159
  | `video({ src, poster })`, `demo({ src, poster })` | a clip or a live page on a slide (§12) |
@@ -165,6 +177,20 @@ decide, and that make or break a page:
165
177
  never the place for the argument.
166
178
  - **Equal heights in a row** (`stretch`), titles at one height, and the empty
167
179
  space at the foot of a page shared by its sections, never left under one.
180
+ - **One arc.** Night, then day, then first light: the dark cover with its
181
+ art, the argument on light pages, one dark statement where the piece turns,
182
+ and the close. A piece that switches ground on every page reads as a
183
+ slideshow of boxes.
184
+ - **No composition three pages running.** The same heading, then the same
185
+ three cards, page after page, is what makes a set of pages read as a
186
+ template; the variety gate names the run. Vary it: a statement set large,
187
+ a split with a photograph, a figure, a page that is one exhibit.
188
+ - **Photographs large or not at all**, through `photo()`: full bleed or a full
189
+ column, never a thumbnail; architecture and light from the brand's set,
190
+ never stock (a person at a desk, a stethoscope, a form, a highway). Every
191
+ photograph takes the brand's one grade, so they read as one shoot.
192
+ - **An exhibit on hairlines, not in a box.** A diagram or a flow sits on the
193
+ ground with rules; a frame around it reads as a dashboard widget.
168
194
  - **A slide balances and sizes itself.** Its body sits in the space under the
169
195
  title, not in the top quarter; a slide whose content fills under 55% of it
170
196
  takes the type scale a step up (`.big`) when that still fits, and the space
@@ -191,7 +217,12 @@ new ─▶ argue ─▶ content ─▶ compose ─▶ build ─▶ review ─▶
191
217
  per page, the contact sheet. `watch` does this on every save in under a
192
218
  second with the browser kept warm.
193
219
  6. **review** — look at `out/sheet.png`, then at any page the gates named.
194
- Read the pages as the reader will: does each carry its claim?
220
+ Read the pages as the reader will: does each carry its claim? Then read
221
+ the sheet as a creative director would, the questions the build prints
222
+ under it: is one page memorable, and which; is the accent rare;
223
+ are photographs large or absent; are hairlines and type doing the
224
+ separating. A piece where no page would stop someone who has seen a
225
+ hundred decks is not finished: make one page that does.
195
226
  7. **ship** — `node wand.js ship <job> [--pptx]`: the build, the one-file HTML,
196
227
  the PowerPoint. A deck's HTML is a talk (§12), a storyboard's plays (§13),
197
228
  and either runtime is driven before it ships. `--brand next` (or `current`)
@@ -204,6 +235,7 @@ new ─▶ argue ─▶ content ─▶ compose ─▶ build ─▶ review ─▶
204
235
  |---|---|---|
205
236
  | fit | anything in the content box runs past it, under the footer, or off the sides | a page that fits and reads badly |
206
237
  | space (warn, slides) | a slide still uses under 40% of its content area after sizing itself | whether the content deserves the slide |
238
+ | variety (warn) | three pages running share one composition (the same blocks in the same order) | two pages composed alike but far apart |
207
239
  | 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 |
208
240
  | overlap | one text box sits on another | text on art |
209
241
  | brand | a computed colour, border or fill that is not a token or a declared theme colour | gradients and images |
@@ -293,7 +325,11 @@ figure final, every poster.
293
325
  - **Kinds.** `rise` (the default), `fade`, `pop` for a mark or a figure,
294
326
  `grow` for a bar going up, `widen` for a bar going across, `draw` for a
295
327
  line that tells a trend (an SVG path with `pathLength="1"`), `count` for a
296
- figure the room should watch arrive (`stat({ count: true })`, `countup()`).
328
+ figure the room should watch arrive (`stat({ count: true })`, `countup()`),
329
+ `words` for a title whose words rise out of their lines (every title does
330
+ this under `motion: "auto"`), `lit` for a statement that lights word by
331
+ word, `window` for a photograph that opens like a window. A page's art
332
+ (the cover's light, a statement, the close) comes up like first light.
297
333
  - **Steps** when the order of the argument is the point: `step(1, html)`, then
298
334
  2, then 3. Never more than four on a slide. Going back undoes the last step.
299
335
  - **Notes on every slide of a talk**, and `seconds` on each when the talk has a
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.2"
7
+ version: "0.3.3"
8
8
  ---
9
9
 
10
10
 
@@ -94,7 +94,9 @@ beside the first. Nothing in the job changes.
94
94
  in `edits` (no em dashes: swap them for commas or semicolons there) and
95
95
  every line left out in `cut`.
96
96
  4. `node wand.js build work/<name>/job.mjs` until every hard gate is ok. Look
97
- at `out/sheet.png`. Open a page only where something looks off.
97
+ at `out/sheet.png`. Open a page only where something looks off. Then answer
98
+ the review questions the build prints under the gates, as a creative
99
+ director would; if the variety row names a run of pages, recompose it.
98
100
  5. `node wand.js ship work/<name>/job.mjs --pptx` for a deck; without `--pptx`
99
101
  for a document. Deliver the PDF (and the .pptx, and the .html) with the
100
102
  review note.
@@ -105,6 +107,10 @@ Skip `content.txt` (delete the `content:` line in the job). Write the page list
105
107
  with its claims first, then the pages. Coverage is skipped and says so; the
106
108
  review reads the argument instead. Three sections a page, not four.
107
109
 
110
+ On the next brand, a title's phrase is its quiet second tone and gold is kept
111
+ for the cover, a statement, the close and the figures; photographs go through
112
+ `c.photo("walk")` (the brand's set, graded), large or not at all.
113
+
108
114
  ### Make it generic (an industry version of a client piece)
109
115
 
110
116
  Author from the client piece: remove the client's figures, names, quotes and
@@ -120,8 +126,9 @@ talk adds:
120
126
  1. Notes on every slide, and `seconds` on each when the talk has a slot.
121
127
  2. Motion where it carries the argument (PROMPT.md §12): `step(n, html)` for
122
128
  a reveal in order, `stat({ count: true })` for a figure to watch arrive,
123
- `enter("draw")` on a trend line, `enter("grow", i)` on bars. Restraint: one
124
- moment a slide.
129
+ `enter("draw")` on a trend line, `enter("grow", i)` on bars, `enter("lit")`
130
+ on a statement, `enter("window")` on a photograph. Titles rise word by word
131
+ on their own. Restraint: one moment a slide.
125
132
  3. A live demo: one self-contained HTML file, then its poster on the deck's
126
133
  brand: `node wand.js poster demo.html --play 3 --brand <brand>`, and
127
134
  `c.demo({ src, poster })` on the slide. A clip: `c.video({ src, poster })`.
@@ -54,7 +54,11 @@
54
54
  "rule": "E4E0F2",
55
55
  "track": "E9E6F5",
56
56
  "surface": "EBE8F8",
57
- "emphLabel": "AA9BFF"
57
+ "emphLabel": "AA9BFF",
58
+ "second": "6C5CE7",
59
+ "secondSm": "6C5CE7",
60
+ "gradeLow": "240F48",
61
+ "gradeHigh": "F8F6FB"
58
62
  },
59
63
  "dark": {
60
64
  "bg": "240F48",
@@ -74,7 +78,11 @@
74
78
  "rule": "3A2E63",
75
79
  "track": "1A1033",
76
80
  "surface": "1A1033",
77
- "emphLabel": "C4B5F4"
81
+ "emphLabel": "C4B5F4",
82
+ "second": "9481FF",
83
+ "secondSm": "9481FF",
84
+ "gradeLow": "240F48",
85
+ "gradeHigh": "F8F6FB"
78
86
  }
79
87
  },
80
88
  "faces": {
@@ -34,6 +34,10 @@
34
34
  "hex": "C4C1CA",
35
35
  "from": "white @ 70% on violet200, lists inside dark cards"
36
36
  },
37
+ "secondDark": {
38
+ "hex": "9D99A8",
39
+ "from": "white @ 55% on violet300 - a title's quiet second tone on dark (5.7:1)"
40
+ },
37
41
  "footDark": {
38
42
  "hex": "888394",
39
43
  "from": "white @ 45% on violet300"
@@ -60,7 +64,11 @@
60
64
  "rule": "D5CEC2",
61
65
  "track": "D5CEC2",
62
66
  "surface": "EBE8E2",
63
- "emphLabel": "B79A5E"
67
+ "emphLabel": "B79A5E",
68
+ "second": "7B7B79",
69
+ "secondSm": "686766",
70
+ "gradeLow": "261D3D",
71
+ "gradeHigh": "F1EFEB"
64
72
  },
65
73
  "dark": {
66
74
  "bg": "261D3D",
@@ -80,7 +88,11 @@
80
88
  "rule": "504666",
81
89
  "track": "504666",
82
90
  "surface": "3A314E",
83
- "emphLabel": "C4C1CA"
91
+ "emphLabel": "C4C1CA",
92
+ "second": "9D99A8",
93
+ "secondSm": "BEBBC5",
94
+ "gradeLow": "261D3D",
95
+ "gradeHigh": "F1EFEB"
84
96
  }
85
97
  },
86
98
  "faces": {
package/lib/components.js CHANGED
@@ -18,9 +18,10 @@
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 fs from "node:fs";
21
22
  import path from "node:path";
22
23
  import { esc } from "./content.js";
23
- import { url } from "./brand.js";
24
+ import { url, brandDir, BRANDS } from "./brand.js";
24
25
 
25
26
  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>`;
26
27
 
@@ -130,13 +131,29 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
130
131
  /** A big outlined numeral in a dark panel on the right (the deck's chapter pages). */
131
132
  const chapter = (num) => `<div class="chapter deco"><span>${esc(num)}</span></div>`;
132
133
 
134
+ /**
135
+ * A photograph, filling its box and given the brand's one grade: blacks lift toward the dark ground,
136
+ * highlights settle toward the light one, so photographs from different shoots read as one set.
137
+ * `src` is a photograph of the brands' set by name ("walk", "corridor"; `brand-next/assets/photos`, which
138
+ * the current brand shares, graded on its own grounds) or a path.
139
+ */
140
+ const photo = (src, { cls: k = "", alt = "", pos = "" } = {}) => {
141
+ let href = /^(file|https?|data):/.test(src) ? src : url(src);
142
+ if (!/[\/.]/.test(src)) {
143
+ const dirs = [brand.assets, ...BRANDS.filter((b) => b !== brand.name).map((b) => { try { return path.join(brandDir(b), "assets"); } catch { return null; } }).filter(Boolean)];
144
+ const hit = dirs.map((d) => path.join(d, "photos", `${src}.jpg`)).find((f) => fs.existsSync(f));
145
+ if (!hit) throw new Error(`photo("${src}"): no photograph of that name in ${dirs.map((d) => path.join(d, "photos")).join(" or ")}`);
146
+ href = url(hit);
147
+ }
148
+ return `<figure class="photo${cls(k)}"><img src="${href}" alt="${esc(alt)}"${pos ? ` style="object-position:${esc(pos)}"` : ""}><span class="grade"></span></figure>`;
149
+ };
133
150
  const img = (src, k = "", alt = "") => `<img class="img${cls(k)}" src="${/^(file|https?|data):/.test(src) ? src : url(src)}" alt="${esc(alt)}">`;
134
151
  const icon = (name, k = "") => brand.glyph(name).replace("<svg", `<svg class="icon deco${cls(k)}" aria-hidden="true"`);
135
152
  const tile = (name) => `<div class="tile deco">${icon(name)}</div>`;
136
153
 
137
154
  /* ---------------------------------------------------------- motion */
138
155
 
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. */
156
+ /** 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, words (a title rising word by word), lit (a statement lighting word by word), window (a photograph opening). */
140
157
  const enter = (kind = "", i = null) => ` data-a="${esc(kind)}"${i != null ? ` style="--i:${+i}"` : ""}`;
141
158
  /** Held back until the presenter moves on; steps of one number come in together. */
142
159
  const step = (nth, html, { a = "", tag: t = "div", cls: k = "" } = {}) => `<${t} data-step="${+nth}"${a ? ` data-a="${esc(a)}"` : ""}${k ? ` class="${k}"` : ""}>${html}</${t}>`;
@@ -235,7 +252,7 @@ ${DIR.some(([k]) => s[k]) ? `<dl class="direction">${DIR.filter(([k]) => s[k]).m
235
252
  page, sec, h1: h("h1"), h2: h("h2"), h3: h("h3"), h4: h("h4"), h5: h("h5"),
236
253
  lead: p("lead"), body: p("body"), note: p("note"), small: p("body sm"), tag, p: (html, k = "") => `<p${cls(k) ? ` class="${k}"` : ""}>${html}</p>`,
237
254
  cols, card, chip, strip, phase, flow, table, who, quote, stat, stats, delta, steps, callout, list: listOf,
238
- cover, closing, chapter, img, icon, tile, mark, wordmark,
255
+ cover, closing, chapter, img, photo, icon, tile, mark, wordmark,
239
256
  asset: brand.assetUrl, art: brand.artUrl,
240
257
  enter, step, countup, video, demo, sketch, storyboard,
241
258
  brand: brand.name, surface: surface.name, count: () => n,
@@ -265,7 +282,11 @@ footer .pn{font-weight:500}
265
282
  /* type */
266
283
  h1,h2,h3,h4,h5,.display{font-family:var(--face-display);font-weight:var(--w-display);letter-spacing:var(--tr-display);line-height:var(--lh-display);color:var(--ink)}
267
284
  h1{font-size:var(--t-h1)}h1.md{font-size:var(--t-h1md)}h2{font-size:var(--t-h2)}h3{font-size:var(--t-h3);line-height:1.22}h4{font-size:var(--t-h4);line-height:1.2}h5{font-size:var(--t-h5);line-height:1.25}
268
- h1 em,h2 em,h3 em,h4 em,.display em,.lede em{font-style:normal;color:var(--accent-text)}
285
+ /* a title's phrase is its second tone (on the current brand, the accent itself); on the cover, a statement
286
+ and the close, the pages that carry the brand's art, it keeps the accent */
287
+ h1 em,h2 em,.display em{font-style:normal;color:var(--second)}
288
+ h3 em,h4 em,h5 em,.lede em{font-style:normal;color:var(--second-sm)}
289
+ .page:has(>.art) :is(h1,h2,h3,h4,h5,.display,.lede) em{color:var(--accent-text)}
269
290
  .label{font-family:var(--face-display);font-weight:500;font-size:var(--t-label);letter-spacing:var(--tr-display);color:var(--label);line-height:1.2}
270
291
  .label.sm{font-size:calc(var(--t-label) * .8)}
271
292
  .lead{font-size:var(--t-lead);line-height:1.5;color:var(--ink)}
@@ -401,6 +422,12 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
401
422
  .has-chapter .left{width:62%;display:flex;flex-direction:column;gap:var(--m-gap)}
402
423
  /* images, icons */
403
424
  .img{display:block;max-width:100%}
425
+ .photo{position:relative;margin:0;overflow:hidden;border-radius:calc(var(--m-radius) * var(--radius-k));background:var(--grade-high)}
426
+ .photo>img{display:block;width:100%;height:100%;object-fit:cover}
427
+ .photo>.grade{position:absolute;inset:0;pointer-events:none}
428
+ .photo>.grade::before,.photo>.grade::after{content:"";position:absolute;inset:0}
429
+ .photo>.grade::before{background:var(--grade-low);mix-blend-mode:lighten;opacity:.42}
430
+ .photo>.grade::after{background:var(--grade-high);mix-blend-mode:darken;opacity:.55}
404
431
  .figure{border-radius:calc(var(--m-radius) * var(--radius-k));overflow:hidden}
405
432
  .icon{width:1.25em;height:1.25em;stroke:currentColor;fill:none}
406
433
  .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}
package/lib/engine.js CHANGED
@@ -113,6 +113,13 @@ function GATES({ min, chars, tokens, families, base }) {
113
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
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("/")); });
115
115
  const seen = new Set();
116
+ // a page's composition, coarsely: the blocks of its content in order, and how many a grid holds
117
+ const kindOf = (el) => { const c = [...el.classList].filter((x) => !/^(deco|big|wide|stretch|centre|sm|md)$/.test(x)).slice(0, 2); return el.tagName.toLowerCase() + (c.length ? "." + c.join(".") : ""); };
118
+ res.sig = pages.map((pg) => {
119
+ const c = pg.querySelector(".content");
120
+ if (!c || pg.matches(".cover, .back, .shot, .run")) return null;
121
+ return [...c.children].filter((k) => !k.classList.contains("deco")).map((k) => kindOf(k) + (/\b(cols|strip|stats|steps|grid)\b/.test(k.className) ? `[${k.children.length}]` : "")).join(" ");
122
+ });
116
123
  pages.forEach((pg, pi) => {
117
124
  const n = pi + 1;
118
125
  const content = pg.querySelector(".content");
@@ -265,6 +272,14 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
265
272
  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
273
  const sparse = auto.fills.filter((f) => f.fill < 0.4);
267
274
  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);
275
+ // the same composition page after page is what makes a set of pages read as a template
276
+ const runs = [], composed = !job.__board && surface.kind !== "card";
277
+ if (composed) for (let i = 1, start = 0; i <= g.sig.length; i++) {
278
+ if (i < g.sig.length && g.sig[i] && g.sig[i] === g.sig[i - 1]) continue;
279
+ if (i - start >= 3 && g.sig[start]) runs.push([start + 1, i]);
280
+ start = i;
281
+ }
282
+ if (composed) row(!runs.length, "variety", runs.length ? runs.map(([a, z]) => `p${a}–p${z}`).join(", ") + " share one composition: vary it (a statement, a split, a figure, a photograph) or join them" : "no composition repeats three pages running", true);
268
283
  row(!g.broken.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}` : "every image loaded");
269
284
  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`);
270
285
  const board = job.__board || null;
@@ -320,6 +335,8 @@ export function printReport(r) {
320
335
  if (r.outputs.pdf) console.log(`\n ${path.relative(process.cwd(), r.outputs.pdf)} (${(fs.statSync(r.outputs.pdf).size / 1e6).toFixed(1)} MB, ${r.pages} page${r.pages === 1 ? "" : "s"}, ${r.brand} brand, ${r.surface}, ${(r.ms / 1000).toFixed(1)}s)`);
321
336
  if (r.outputs.pptx) console.log(` ${path.relative(process.cwd(), r.outputs.pptx)} (${(fs.statSync(r.outputs.pptx).size / 1e6).toFixed(1)} MB)`);
322
337
  if (r.outputs.single) console.log(` ${path.relative(process.cwd(), r.outputs.single)} (${(fs.statSync(r.outputs.single).size / 1e6).toFixed(1)} MB, one file, opens offline)`);
338
+ // what no gate sees: read the sheet the way a creative director would, before anyone else does
339
+ if (r.outputs.sheet) console.log(`\n review ${path.relative(process.cwd(), r.outputs.sheet)}, as a creative director:\n does every page carry its one claim? is one page memorable, and which?\n is the accent rare (cover, statement, close, figures)? photographs large or absent?\n hairlines and type doing the separating, not boxes?`);
323
340
  console.log(r.ok ? "" : "\n FAILED — fix the rows marked FAIL\n");
324
341
  }
325
342
 
package/lib/talk.js CHANGED
@@ -10,9 +10,11 @@
10
10
  * What it adds to the pages, none of which the PDF, the PNGs or the .pptx see
11
11
  * (they are read from the static render):
12
12
  * - a crossfade between slides, the slide being left kept under the next;
13
- * - entrances: `data-a` (rise, fade, pop, grow, widen, draw, count) with `--i` as
14
- * the stagger, played going forward; with `motion: "auto"` a slide that
15
- * marks nothing has its blocks staggered in for it;
13
+ * - entrances: `data-a` (rise, fade, pop, grow, widen, draw, count, words,
14
+ * lit, window) with `--i` as the stagger, played going forward; with
15
+ * `motion: "auto"` a slide that marks nothing has its blocks staggered in
16
+ * for it, its title's words rising out of their lines, and a page's art
17
+ * comes up like first light;
16
18
  * - steps: `data-step="n"` holds an element back until the presenter moves
17
19
  * on, a group per number; going back undoes the last group;
18
20
  * - media: a `.media.video` plays while its slide is on, a `.media.demo`
@@ -137,6 +139,19 @@ body.notes-open .viewport{grid-template-columns:1fr min(440px,34vw)}
137
139
  @keyframes wa-grow{from{transform:scaleY(0)}}
138
140
  @keyframes wa-widen{from{transform:scaleX(0)}}
139
141
  @keyframes wa-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
142
+ /* a title's words rise out of their lines; a statement lights word by word; a photograph opens like a window */
143
+ .wa-w{display:inline-block;overflow:clip;overflow-clip-margin:.14em;vertical-align:top;padding-bottom:.08em;margin-bottom:-.08em}
144
+ .wa-wi{display:inline-block}
145
+ .page.play [data-a="words"],.page.play [data-a="lit"]{animation:none}
146
+ .page.play [data-a="words"] .wa-wi{animation:wa-word 760ms var(--ease) both;animation-delay:calc(min(var(--i,0),12) * 70ms + min(var(--wi,0),24) * 40ms + 60ms)}
147
+ .page.play [data-a="lit"] .wa-wi{animation:wa-lit 420ms linear both;animation-delay:calc(min(var(--i,0),12) * 70ms + min(var(--wi,0),40) * 70ms + 240ms)}
148
+ .page.play [data-a="window"]{animation-name:wa-window;animation-duration:1300ms}
149
+ /* the art of a page (the cover's light, a statement, the close) comes up like first light */
150
+ .page.play>.art{animation:wa-dawn 1800ms var(--ease) both}
151
+ @keyframes wa-word{from{transform:translateY(105%)}}
152
+ @keyframes wa-lit{from{opacity:.16}}
153
+ @keyframes wa-window{from{clip-path:inset(16% 24%);transform:scale(1.06)}to{clip-path:inset(0)}}
154
+ @keyframes wa-dawn{from{opacity:0;transform:translateY(3%)}}
140
155
 
141
156
  /* media: posters until the slide is on and the media is ready */
142
157
  .talk-on .stage .media.video.playing>.poster,.talk-on .stage .media.demo.is-ready>.poster{opacity:0;transition:opacity 400ms var(--ease-io)}
@@ -270,11 +285,29 @@ export const TALK_JS = String.raw`(() => {
270
285
  if (s.dataset.motion === "none" || $('[data-a]:not([data-a="count"]), [data-step]', s)) return;
271
286
  const content = $(".content", s); if (!content) return;
272
287
  let i = 0;
273
- const mark = (el) => { if (el.classList.contains("deco") || el.matches("script, style")) return; if (!el.hasAttribute("data-a")) el.setAttribute("data-a", ""); el.style.setProperty("--i", i++); };
288
+ const mark = (el) => { if (el.classList.contains("deco") || el.matches("script, style")) return; if (!el.hasAttribute("data-a")) el.setAttribute("data-a", el.matches("h1, h2, .display") ? "words" : ""); el.style.setProperty("--i", i++); };
274
289
  const walk = (el) => { for (const ch of el.children) { if (ch.matches(GROUPS) && ch.children.length > 1) { for (const g of ch.children) mark(g); } else if (ch.matches(".top, .bottom") ) walk(ch); else mark(ch); } };
275
290
  walk(content);
276
291
  });
277
292
 
293
+ /* the words of a rising title or a lit statement, each in its own line box (spaces stay text, so lines break as before) */
294
+ $$('[data-a="words"], [data-a="lit"]').forEach((el) => {
295
+ let n = 0;
296
+ const walk = (node) => [...node.childNodes].forEach((ch) => {
297
+ if (ch.nodeType === 3) {
298
+ const frag = document.createDocumentFragment();
299
+ ch.data.split(/(\s+)/).forEach((part) => {
300
+ if (!part) return;
301
+ if (/^\s+$/.test(part)) { frag.append(document.createTextNode(part)); return; }
302
+ const w = document.createElement("span"), i = document.createElement("span");
303
+ w.className = "wa-w"; i.className = "wa-wi"; i.style.setProperty("--wi", n++); i.textContent = part; w.append(i); frag.append(w);
304
+ });
305
+ ch.replaceWith(frag);
306
+ } else if (ch.nodeType === 1 && !ch.classList.contains("wa-w")) walk(ch);
307
+ });
308
+ walk(el);
309
+ });
310
+
278
311
  /* ------------------------------------------------------------- the scale */
279
312
  function fit(box) { const k = box.clientWidth / W; if (k) box.style.setProperty("--k", k); }
280
313
  new ResizeObserver(() => { fit(stage); $$(".mini").forEach(fit); }).observe(stage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wand-kit",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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": {
package/wand.js CHANGED
@@ -185,7 +185,7 @@ async function components() {
185
185
  tag: "tag(text) a caps label", cols: "cols(n, [items], { stretch }) a grid", card: "card({ tag, title, level, body, out, emph, sm })", callout: "callout({ tag, text, emph })",
186
186
  strip: "strip([labels], { on }) numbered chips with arrows", chip: "chip(i, label, state)", phase: "phase(i, label, line) chip then heading", flow: "flow({ label, nodes: [[who, verb, 'a'|'p']], note })",
187
187
  table: "table({ head, rows, emph }) cells: string or { v, meter, cls }", quote: "quote({ text, by, emph })", who: "who(name, role)", stats: "stats([{ v, k, hero }])", delta: "delta(from, to)",
188
- steps: "steps([{ title, when, body, emph }])", list: "list([items])", chapter: "chapter(num) the deck's numeral panel", img: "img(src, cls, alt)", icon: "icon(name) a brand glyph", tile: "tile(name) a glyph on a tile",
188
+ steps: "steps([{ title, when, body, emph }])", list: "list([items])", chapter: "chapter(num) the deck's numeral panel", img: "img(src, cls, alt)", photo: "photo(nameOrPath, { cls, alt, pos }) a photograph, filled and graded", icon: "icon(name) a brand glyph", tile: "tile(name) a glyph on a tile",
189
189
  accent: "accent(text, phrase) the phrase in the accent colour", split: "split(line, sep)", L: "L(n) line n of content.txt", esc: "esc(s)", art: "art(role) a brand art url", asset: "asset(file)",
190
190
  };
191
191
  console.log("");