wand-kit 0.3.1 → 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,26 @@ 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.
194
+ - **A slide balances and sizes itself.** Its body sits in the space under the
195
+ title, not in the top quarter; a slide whose content fills under 55% of it
196
+ takes the type scale a step up (`.big`) when that still fits, and the space
197
+ gate names any slide still under 40%. A thin slide wants its proof, a
198
+ figure or a visual, or to be joined to the next; `autosize: false` on a page
199
+ keeps it as composed.
168
200
 
169
201
  ## 6. Pipeline
170
202
 
@@ -185,7 +217,12 @@ new ─▶ argue ─▶ content ─▶ compose ─▶ build ─▶ review ─▶
185
217
  per page, the contact sheet. `watch` does this on every save in under a
186
218
  second with the browser kept warm.
187
219
  6. **review** — look at `out/sheet.png`, then at any page the gates named.
188
- 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.
189
226
  7. **ship** — `node wand.js ship <job> [--pptx]`: the build, the one-file HTML,
190
227
  the PowerPoint. A deck's HTML is a talk (§12), a storyboard's plays (§13),
191
228
  and either runtime is driven before it ships. `--brand next` (or `current`)
@@ -197,6 +234,8 @@ new ─▶ argue ─▶ content ─▶ compose ─▶ build ─▶ review ─▶
197
234
  | Gate | Fails when | Blind to |
198
235
  |---|---|---|
199
236
  | fit | anything in the content box runs past it, under the footer, or off the sides | a page that fits and reads badly |
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 |
200
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 |
201
240
  | overlap | one text box sits on another | text on art |
202
241
  | brand | a computed colour, border or fill that is not a token or a declared theme colour | gradients and images |
@@ -286,7 +325,11 @@ figure final, every poster.
286
325
  - **Kinds.** `rise` (the default), `fade`, `pop` for a mark or a figure,
287
326
  `grow` for a bar going up, `widen` for a bar going across, `draw` for a
288
327
  line that tells a trend (an SVG path with `pathLength="1"`), `count` for a
289
- 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.
290
333
  - **Steps** when the order of the argument is the point: `step(1, html)`, then
291
334
  2, then 3. Never more than four on a slide. Going back undoes the last step.
292
335
  - **Notes on every slide of a talk**, and `seconds` on each when the talk has a
@@ -322,9 +365,10 @@ direction (the line, camera, motion, on screen, sound, into next).
322
365
  after; a shot without voiceover `hold`s; `seconds` on a shot overrides the
323
366
  estimate once a real read exists. `target` warns when the board is more
324
367
  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
368
+ - **A frame is drawn five ways:** `sketch` (people as dots, software as
326
369
  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
370
+ brand's colours), `endcard` (the wordmark on the brand's ground, a line
371
+ under it when given), the job's own SVG on brand roles, an image (a product
328
372
  screen, a demo poster), or left to draw, which the frames gate counts. The
329
373
  first frame is on screen from the cut; the others land on their cues.
330
374
  - **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.1"
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,12 +126,16 @@ 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 })`.
128
- 4. `ship` drives the HTML like a presenter before it ships (the runtime row).
135
+ 4. Slides balance and size themselves; if the space row names one, it is
136
+ thin: add its proof or a figure, or join it to the next, rather than
137
+ stretching what is there.
138
+ 5. `ship` drives the HTML like a presenter before it ships (the runtime row).
129
139
  Deliver the HTML as the deck to present, with the PDF and .pptx beside it.
130
140
 
131
141
  ### A storyboard from a script
@@ -134,8 +144,9 @@ talk adds:
134
144
  edit will cut). `node wand.js new work/<name> --surface storyboard`.
135
145
  2. **Write the shot list in the chat first**: act, name, the line, and what
136
146
  each frame shows. Then the job: `c.storyboard({ title, wpm, target, shots })`.
137
- 3. Frames: `sketch` for a quick one in the brand's colours, the job's own SVG,
138
- an image, or left to draw (the frames gate counts them).
147
+ 3. Frames: `sketch` for a quick one in the brand's colours, `endcard: true`
148
+ for the wordmark close, the job's own SVG, an image, or left to draw (the
149
+ frames gate counts them). A held close is a shot with no `vo` and a `hold`.
139
150
  4. `ship`: the board as PDF and .pptx, `timing.csv`, `captions.srt`, and the
140
151
  HTML with its player. Say in the note that timing is scratch timing at the
141
152
  `wpm` given until a real read exists, and which frames are still to draw.
@@ -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": {
@@ -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
@@ -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
 
@@ -52,11 +53,11 @@ export function bind({ brand, surface, job, content, dir: jobDir = process.cwd()
52
53
  * view; `seconds` is the slide's share of the talk, for the presenter's
53
54
  * pace; `motion: "none"` keeps a slide still when the deck animates.
54
55
  */
55
- const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "" } = {}) => {
56
+ const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "", autosize = true } = {}) => {
56
57
  n += 1;
57
58
  const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(art)) : null;
58
59
  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)}"` : ""}>
60
+ 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
61
  ${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
61
62
  ${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
62
63
  <div class="content">${body}</div>
@@ -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}>`;
@@ -190,7 +207,8 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
190
207
  * on its `cue` word. The timeline is kept for the CSV, the captions and the
191
208
  * player in the HTML.
192
209
  * A shot: { act, name, vo, line, camera, motion, onscreen, sound, next,
193
- * lead, tail, hold, seconds, frames: [{ cue, caption, art | img | sketch }] }
210
+ * lead, tail, hold, seconds, frames: [{ cue, caption, art | img | sketch | endcard }] }
211
+ * `endcard: true` draws the wordmark on the brand's ground; a string adds a line under it.
194
212
  */
195
213
  const storyboard = ({ title, lede = "", wpm = 150, target = 0, shots = [], dark = true, cover: withCover = true, index = true, audio = "" }) => {
196
214
  let t = 0;
@@ -204,14 +222,16 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
204
222
  const at = f.cue && vo ? vo.toLowerCase().indexOf(String(f.cue).toLowerCase()) : -1;
205
223
  // the first frame is on screen from the cut; the others land on their cue words
206
224
  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) };
225
+ 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
226
  });
209
227
  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
228
  });
211
229
  const total = t;
212
230
  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
231
 
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>`;
232
+ // an end card is the wordmark on the brand's ground, with a line under it when endcard is a string
233
+ 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>`;
234
+ 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
235
  const DIR = [["line", "The line"], ["camera", "Camera"], ["motion", "Motion"], ["onscreen", "On screen"], ["sound", "Sound"], ["next", "Into next"]];
216
236
  const shotPage = (s) => page({ eyebrow: s.act || "", dark, cls: "shot", notes: s.vo, body: `
217
237
  <div class="shot-head"><div class="shot-id"><span class="tag">Shot</span><span class="no">${String(s.n).padStart(2, "0")}</span></div>
@@ -232,7 +252,7 @@ ${DIR.some(([k]) => s[k]) ? `<dl class="direction">${DIR.filter(([k]) => s[k]).m
232
252
  page, sec, h1: h("h1"), h2: h("h2"), h3: h("h3"), h4: h("h4"), h5: h("h5"),
233
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>`,
234
254
  cols, card, chip, strip, phase, flow, table, who, quote, stat, stats, delta, steps, callout, list: listOf,
235
- cover, closing, chapter, img, icon, tile, mark, wordmark,
255
+ cover, closing, chapter, img, photo, icon, tile, mark, wordmark,
236
256
  asset: brand.assetUrl, art: brand.artUrl,
237
257
  enter, step, countup, video, demo, sketch, storyboard,
238
258
  brand: brand.name, surface: surface.name, count: () => n,
@@ -262,7 +282,11 @@ footer .pn{font-weight:500}
262
282
  /* type */
263
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)}
264
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}
265
- 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)}
266
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}
267
291
  .label.sm{font-size:calc(var(--t-label) * .8)}
268
292
  .lead{font-size:var(--t-lead);line-height:1.5;color:var(--ink)}
@@ -398,11 +422,20 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
398
422
  .has-chapter .left{width:62%;display:flex;flex-direction:column;gap:var(--m-gap)}
399
423
  /* images, icons */
400
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}
401
431
  .figure{border-radius:calc(var(--m-radius) * var(--radius-k));overflow:hidden}
402
432
  .icon{width:1.25em;height:1.25em;stroke:currentColor;fill:none}
403
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}
404
434
  .card.emph .tile{background:var(--emph-accent);color:var(--emph)}
405
435
  /* room */
436
+ /* a slide's body sits in the space under its title, not in its top quarter */
437
+ .surface-slide .page:not(.cover):not(.back):not(.shot):not(.run) .content>:is(h1,h2,.sec):first-child+*{margin-top:auto}
438
+ .surface-slide .page:not(.cover):not(.back):not(.shot):not(.run) .content>:last-child{margin-bottom:auto}
406
439
  .roomy .content{gap:calc(var(--m-gap) * 1.3)}
407
440
  .roomy .cols{margin:auto 0}
408
441
  .roomy .lead{font-size:calc(var(--t-lead) * 1.15)}
@@ -448,6 +481,9 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
448
481
  .frame figcaption b{color:var(--label);font-weight:600}
449
482
  .frame figcaption span{display:block;color:var(--text);margin-top:.2em}
450
483
  .frame .todo{position:absolute;inset:0;display:grid;place-items:center;border:2px dashed var(--rule);border-radius:inherit}
484
+ .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}
485
+ .frame-art .endcard>img{position:static;width:30%;height:auto}
486
+ .frame-art .endcard>span{font-family:var(--face-body);font-size:var(--t-sm);color:var(--soft);letter-spacing:.04em}
451
487
  .frame .todo span{font-size:var(--t-sm);color:var(--soft)}
452
488
  .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)}
453
489
  .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
@@ -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");
@@ -172,6 +179,35 @@ function GATES({ min, chars, tokens, families, base }) {
172
179
  return res;
173
180
  }
174
181
 
182
+ /**
183
+ * Runs inside the page. A slide whose content fills too little of it gets the
184
+ * type scale one step up (.big), kept only if nothing then overflows: a deck
185
+ * sizes itself to its slides, the same way on every run. Returns the pages
186
+ * it changed, and every slide's fill after.
187
+ */
188
+ function AUTOSIZE() {
189
+ const fill = (pg) => {
190
+ const c = pg.querySelector(".content"); if (!c) return 1;
191
+ const kids = [...c.children].filter((k) => !k.classList.contains("deco") && k.getBoundingClientRect().height);
192
+ if (!kids.length) return 1;
193
+ const gap = parseFloat(getComputedStyle(c).rowGap) || 0;
194
+ return (kids.reduce((a, k) => a + k.getBoundingClientRect().height, 0) + gap * (kids.length - 1)) / c.getBoundingClientRect().height;
195
+ };
196
+ const overflows = (pg) => {
197
+ const c = pg.querySelector(".content"), box = c.getBoundingClientRect(), foot = pg.querySelector("footer")?.getBoundingClientRect();
198
+ const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
199
+ // as the fit gate reads it: an inline run's line box may rise above the content's top, a block may not
200
+ 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;
201
+ };
202
+ const sized = [], fills = [];
203
+ document.querySelectorAll(".page").forEach((pg, i) => {
204
+ if (pg.matches(".cover, .back, .shot, .run, [data-size]")) return;
205
+ 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); }
206
+ fills.push({ page: i + 1, fill: fill(pg) });
207
+ });
208
+ return { sized, fills };
209
+ }
210
+
175
211
  /* ------------------------------------------------------------------ build */
176
212
 
177
213
  /**
@@ -209,6 +245,9 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
209
245
  await page.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
210
246
  await page.waitForTimeout(120);
211
247
 
248
+ // a slide deck sizes itself before anything is measured or drawn; the file keeps the sizes it chose
249
+ const auto = surface.kind === "slide" ? await page.evaluate(AUTOSIZE) : { sized: [], fills: [] };
250
+ if (auto.sized.length) fs.writeFileSync(htmlPath, "<!doctype html>\n" + await page.evaluate(() => document.documentElement.outerHTML));
212
251
  const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base });
213
252
  const pageCount = g.fit.length;
214
253
  const text = g.texts.join("\n");
@@ -231,6 +270,16 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
231
270
  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
271
  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
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);
273
+ const sparse = auto.fills.filter((f) => f.fill < 0.4);
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);
234
283
  row(!g.broken.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}` : "every image loaded");
235
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`);
236
285
  const board = job.__board || null;
@@ -286,6 +335,8 @@ export function printReport(r) {
286
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)`);
287
336
  if (r.outputs.pptx) console.log(` ${path.relative(process.cwd(), r.outputs.pptx)} (${(fs.statSync(r.outputs.pptx).size / 1e6).toFixed(1)} MB)`);
288
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?`);
289
340
  console.log(r.ok ? "" : "\n FAILED — fix the rows marked FAIL\n");
290
341
  }
291
342
 
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/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.1",
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("");