wand-kit 0.3.1 → 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 +10 -2
- package/SKILL.md +8 -4
- package/examples/storyboard/job.mjs +3 -3
- package/lib/components.js +14 -5
- package/lib/engine.js +34 -0
- package/lib/surfaces.js +4 -1
- package/package.json +1 -1
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
|
|
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
|
|
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.
|
|
7
|
+
version: "0.3.2"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
|
|
@@ -125,7 +125,10 @@ talk adds:
|
|
|
125
125
|
3. A live demo: one self-contained HTML file, then its poster on the deck's
|
|
126
126
|
brand: `node wand.js poster demo.html --play 3 --brand <brand>`, and
|
|
127
127
|
`c.demo({ src, poster })` on the slide. A clip: `c.video({ src, poster })`.
|
|
128
|
-
4.
|
|
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).
|
|
129
132
|
Deliver the HTML as the deck to present, with the PDF and .pptx beside it.
|
|
130
133
|
|
|
131
134
|
### A storyboard from a script
|
|
@@ -134,8 +137,9 @@ talk adds:
|
|
|
134
137
|
edit will cut). `node wand.js new work/<name> --surface storyboard`.
|
|
135
138
|
2. **Write the shot list in the chat first**: act, name, the line, and what
|
|
136
139
|
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,
|
|
138
|
-
an image, or left to draw (the
|
|
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`.
|
|
139
143
|
4. `ship`: the board as PDF and .pptx, `timing.csv`, `captions.srt`, and the
|
|
140
144
|
HTML with its player. Say in the note that timing is scratch timing at the
|
|
141
145
|
`wpm` given until a real read exists, and which frames are still to draw.
|
|
@@ -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
|
|
9
|
-
*
|
|
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",
|
|
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
|
-
|
|
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>
|
|
@@ -403,6 +406,9 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
|
|
|
403
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}
|
|
404
407
|
.card.emph .tile{background:var(--emph-accent);color:var(--emph)}
|
|
405
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}
|
|
406
412
|
.roomy .content{gap:calc(var(--m-gap) * 1.3)}
|
|
407
413
|
.roomy .cols{margin:auto 0}
|
|
408
414
|
.roomy .lead{font-size:calc(var(--t-lead) * 1.15)}
|
|
@@ -448,6 +454,9 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
|
|
|
448
454
|
.frame figcaption b{color:var(--label);font-weight:600}
|
|
449
455
|
.frame figcaption span{display:block;color:var(--text);margin-top:.2em}
|
|
450
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}
|
|
451
460
|
.frame .todo span{font-size:var(--t-sm);color:var(--soft)}
|
|
452
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)}
|
|
453
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.
|
|
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": {
|