wand-kit 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/PROMPT.md +221 -19
  2. package/SKILL.md +100 -20
  3. package/START-HERE.md +12 -1
  4. package/brands/current/tokens.json +10 -2
  5. package/brands/interim/assets/avatar.svg +1 -0
  6. package/brands/interim/assets/closing-art.png +0 -0
  7. package/brands/interim/assets/cover-art.png +0 -0
  8. package/brands/interim/assets/cover-portrait-art.png +0 -0
  9. package/brands/interim/assets/divider-art.png +0 -0
  10. package/brands/interim/assets/glyphs/README.md +29 -0
  11. package/brands/interim/assets/glyphs/activity.svg +15 -0
  12. package/brands/interim/assets/glyphs/atom.svg +17 -0
  13. package/brands/interim/assets/glyphs/badge-check.svg +16 -0
  14. package/brands/interim/assets/glyphs/bell.svg +16 -0
  15. package/brands/interim/assets/glyphs/blocks.svg +16 -0
  16. package/brands/interim/assets/glyphs/book-open.svg +16 -0
  17. package/brands/interim/assets/glyphs/bot.svg +20 -0
  18. package/brands/interim/assets/glyphs/boxes.svg +26 -0
  19. package/brands/interim/assets/glyphs/brain.svg +23 -0
  20. package/brands/interim/assets/glyphs/briefcase.svg +16 -0
  21. package/brands/interim/assets/glyphs/building-2.svg +21 -0
  22. package/brands/interim/assets/glyphs/calendar.svg +18 -0
  23. package/brands/interim/assets/glyphs/chart-column.svg +18 -0
  24. package/brands/interim/assets/glyphs/chart-line.svg +16 -0
  25. package/brands/interim/assets/glyphs/chart-pie.svg +16 -0
  26. package/brands/interim/assets/glyphs/circle-check.svg +16 -0
  27. package/brands/interim/assets/glyphs/clipboard-check.svg +17 -0
  28. package/brands/interim/assets/glyphs/clock.svg +16 -0
  29. package/brands/interim/assets/glyphs/cloud.svg +15 -0
  30. package/brands/interim/assets/glyphs/code.svg +15 -0
  31. package/brands/interim/assets/glyphs/coins.svg +18 -0
  32. package/brands/interim/assets/glyphs/compass.svg +16 -0
  33. package/brands/interim/assets/glyphs/cpu.svg +24 -0
  34. package/brands/interim/assets/glyphs/database.svg +17 -0
  35. package/brands/interim/assets/glyphs/eye.svg +16 -0
  36. package/brands/interim/assets/glyphs/file-check.svg +17 -0
  37. package/brands/interim/assets/glyphs/filter.svg +15 -0
  38. package/brands/interim/assets/glyphs/fingerprint.svg +23 -0
  39. package/brands/interim/assets/glyphs/flag.svg +16 -0
  40. package/brands/interim/assets/glyphs/gauge.svg +16 -0
  41. package/brands/interim/assets/glyphs/gavel.svg +19 -0
  42. package/brands/interim/assets/glyphs/git-branch.svg +18 -0
  43. package/brands/interim/assets/glyphs/globe.svg +17 -0
  44. package/brands/interim/assets/glyphs/graduation-cap.svg +17 -0
  45. package/brands/interim/assets/glyphs/handshake.svg +19 -0
  46. package/brands/interim/assets/glyphs/hexagon.svg +15 -0
  47. package/brands/interim/assets/glyphs/index.json +177 -0
  48. package/brands/interim/assets/glyphs/infinity.svg +15 -0
  49. package/brands/interim/assets/glyphs/key.svg +17 -0
  50. package/brands/interim/assets/glyphs/landmark.svg +20 -0
  51. package/brands/interim/assets/glyphs/layers.svg +17 -0
  52. package/brands/interim/assets/glyphs/lightbulb.svg +17 -0
  53. package/brands/interim/assets/glyphs/link.svg +16 -0
  54. package/brands/interim/assets/glyphs/list-checks.svg +19 -0
  55. package/brands/interim/assets/glyphs/lock.svg +16 -0
  56. package/brands/interim/assets/glyphs/map.svg +17 -0
  57. package/brands/interim/assets/glyphs/megaphone.svg +16 -0
  58. package/brands/interim/assets/glyphs/message-square.svg +15 -0
  59. package/brands/interim/assets/glyphs/milestone.svg +17 -0
  60. package/brands/interim/assets/glyphs/network.svg +19 -0
  61. package/brands/interim/assets/glyphs/orbit.svg +19 -0
  62. package/brands/interim/assets/glyphs/package.svg +18 -0
  63. package/brands/interim/assets/glyphs/plug.svg +18 -0
  64. package/brands/interim/assets/glyphs/puzzle.svg +15 -0
  65. package/brands/interim/assets/glyphs/radar.svg +22 -0
  66. package/brands/interim/assets/glyphs/receipt.svg +17 -0
  67. package/brands/interim/assets/glyphs/refresh-cw.svg +18 -0
  68. package/brands/interim/assets/glyphs/rocket.svg +18 -0
  69. package/brands/interim/assets/glyphs/route.svg +17 -0
  70. package/brands/interim/assets/glyphs/scale.svg +19 -0
  71. package/brands/interim/assets/glyphs/search.svg +16 -0
  72. package/brands/interim/assets/glyphs/server.svg +18 -0
  73. package/brands/interim/assets/glyphs/settings.svg +16 -0
  74. package/brands/interim/assets/glyphs/share-2.svg +19 -0
  75. package/brands/interim/assets/glyphs/shield-check.svg +16 -0
  76. package/brands/interim/assets/glyphs/shuffle.svg +19 -0
  77. package/brands/interim/assets/glyphs/sparkles.svg +19 -0
  78. package/brands/interim/assets/glyphs/square-stack.svg +17 -0
  79. package/brands/interim/assets/glyphs/target.svg +17 -0
  80. package/brands/interim/assets/glyphs/trending-up.svg +16 -0
  81. package/brands/interim/assets/glyphs/triangle-alert.svg +17 -0
  82. package/brands/interim/assets/glyphs/trophy.svg +20 -0
  83. package/brands/interim/assets/glyphs/users.svg +18 -0
  84. package/brands/interim/assets/glyphs/wallet.svg +16 -0
  85. package/brands/interim/assets/glyphs/wand-sparkles.svg +22 -0
  86. package/brands/interim/assets/glyphs/workflow.svg +17 -0
  87. package/brands/interim/assets/glyphs/zap.svg +15 -0
  88. package/brands/interim/assets/mark.svg +1 -0
  89. package/brands/interim/assets/social/composition.json +6833 -0
  90. package/brands/interim/assets/spark-lavender.png +0 -0
  91. package/brands/interim/assets/spark-violet.png +0 -0
  92. package/brands/interim/assets/statement-art.png +0 -0
  93. package/brands/interim/assets/story/agent-alpha.png +0 -0
  94. package/brands/interim/assets/story/agent.png +0 -0
  95. package/brands/interim/assets/story/economy-alpha.png +0 -0
  96. package/brands/interim/assets/story/economy.png +0 -0
  97. package/brands/interim/assets/story/index.json +214 -0
  98. package/brands/interim/assets/story/learning-alpha.png +0 -0
  99. package/brands/interim/assets/story/learning.png +0 -0
  100. package/brands/interim/assets/story/organization-alpha.png +0 -0
  101. package/brands/interim/assets/story/organization.png +0 -0
  102. package/brands/interim/assets/story/worker-alpha.png +0 -0
  103. package/brands/interim/assets/story/worker-light.png +0 -0
  104. package/brands/interim/assets/story/worker.png +0 -0
  105. package/brands/interim/assets/wordmark-on-dark.png +0 -0
  106. package/brands/interim/assets/wordmark-on-dark.svg +1 -0
  107. package/brands/interim/assets/wordmark-on-light.png +0 -0
  108. package/brands/interim/assets/wordmark-on-light.svg +1 -0
  109. package/brands/interim/fonts/Geist-Book.ttf +0 -0
  110. package/brands/interim/fonts/Geist-Medium.ttf +0 -0
  111. package/brands/interim/fonts/Geist-Regular.ttf +0 -0
  112. package/brands/interim/fonts/Geist-SemiBold.ttf +0 -0
  113. package/brands/interim/fonts/Newsreader-Italic.ttf +0 -0
  114. package/brands/interim/fonts/Newsreader-Medium.ttf +0 -0
  115. package/brands/interim/fonts/Newsreader-MediumItalic.ttf +0 -0
  116. package/brands/interim/fonts/Newsreader-Regular.ttf +0 -0
  117. package/brands/interim/fonts/OFL-Geist.txt +93 -0
  118. package/brands/interim/fonts/OFL-Newsreader.txt +93 -0
  119. package/brands/interim/fonts/faces.json +12 -0
  120. package/brands/interim/lib/composition.js +169 -0
  121. package/brands/interim/lib/composition.mjs +101 -0
  122. package/brands/interim/lib/glyphs.js +89 -0
  123. package/brands/interim/lib/measure.js +121 -0
  124. package/brands/interim/lib/pdf.js +597 -0
  125. package/brands/interim/lib/ttf.py +169 -0
  126. package/brands/interim/lib/wandxml.py +37 -0
  127. package/brands/interim/tokens.json +299 -0
  128. package/brands/next/tokens.json +14 -2
  129. package/examples/social/social.json +23 -0
  130. package/lib/audit.js +142 -0
  131. package/lib/brand.js +12 -7
  132. package/lib/components.js +42 -10
  133. package/lib/engine.js +72 -9
  134. package/lib/library.js +180 -0
  135. package/lib/pptx.js +1 -1
  136. package/lib/social.js +366 -0
  137. package/lib/talk.js +37 -4
  138. package/library/index.json +629 -0
  139. package/library/interim/avatar-800x800.png +0 -0
  140. package/library/interim/icons/apple-touch-icon.png +0 -0
  141. package/library/interim/icons/favicon-16.png +0 -0
  142. package/library/interim/icons/favicon-32.png +0 -0
  143. package/library/interim/icons/favicon-48.png +0 -0
  144. package/library/interim/icons/favicon.ico +0 -0
  145. package/library/interim/icons/favicon.svg +1 -0
  146. package/library/interim/icons/icon-192.png +0 -0
  147. package/library/interim/icons/icon-512.png +0 -0
  148. package/library/interim/icons/icon-tags.html +5 -0
  149. package/library/interim/icons/site.webmanifest +19 -0
  150. package/library/interim/linkedin-banner-1584x396.png +0 -0
  151. package/library/interim/linkedin-company-1128x191.png +0 -0
  152. package/library/interim/open-agent-discovery-share-1200x630.png +0 -0
  153. package/library/interim/open-agent-discovery-share-head-tags.html +17 -0
  154. package/library/interim/wand-ai-share-1200x630.png +0 -0
  155. package/library/interim/wand-ai-share-head-tags.html +17 -0
  156. package/library/interim/x-header-1500x500.png +0 -0
  157. package/package.json +2 -2
  158. package/templates/card.mjs +6 -2
  159. package/templates/page.mjs +1 -1
  160. package/templates/slide.mjs +1 -1
  161. package/templates/storyboard.mjs +1 -1
  162. package/wand.js +65 -6
package/lib/audit.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * lib/audit.js — how a page's link preview looks today, before anyone redraws it.
3
+ *
4
+ * The 2026-10-23 fix (runs/2026-10-23-wand-og-fix) began by reading the live
5
+ * pages: one image for every page, its words outside the centre square Slack
6
+ * keeps, small print that vanished in LinkedIn's compact card, an og:title that
7
+ * repeated the image, an em dash, no twitter tags. This reads the same things
8
+ * from any page and shows its image the way those feeds do.
9
+ *
10
+ * node wand.js social --audit https://wand.ai/ https://wand.ai/insights/… [--out audit]
11
+ * node wand.js social --audit saved-page.html --image its-og-image.png
12
+ *
13
+ * A chat with no web access gets a clear row saying so: save the page (and its
14
+ * image) and pass the files.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import { browser } from "./engine.js";
20
+ import { fetchTo, feedRows, wholeWithSquare, previewPage } from "./social.js";
21
+
22
+ const LIMIT = 300 * 1024;
23
+ const ENT = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: " ", mdash: "—", ndash: "–", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", hellip: "…" };
24
+ const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => e[0] === "#" ? String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : +e.slice(1)) : ENT[e.toLowerCase()] ?? m);
25
+
26
+ /** The page's og: and twitter: tags (the first of each, as Slack reads them), how many twitter:card tags, and its <title>. */
27
+ export function readTags(html) {
28
+ const tags = {}, count = {};
29
+ for (const [m] of html.matchAll(/<meta\b[^>]*>/gi)) {
30
+ const attr = {};
31
+ for (const [, k, , a, b] of m.matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) attr[k.toLowerCase()] = decode(a ?? b ?? "");
32
+ const key = (attr.property || attr.name || "").toLowerCase();
33
+ if (!/^(og|twitter):/.test(key) || attr.content == null) continue;
34
+ count[key] = (count[key] || 0) + 1;
35
+ if (!(key in tags)) tags[key] = attr.content;
36
+ }
37
+ const title = decode((/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || [])[1] || "").trim();
38
+ return { tags, count, title };
39
+ }
40
+
41
+ /** Width, height and type from a PNG, JPEG, GIF or WebP's own header. */
42
+ export function imageFacts(file) {
43
+ const b = fs.readFileSync(file), bytes = b.length;
44
+ if (b[0] === 0x89 && b.toString("ascii", 1, 4) === "PNG") return { type: "png", w: b.readUInt32BE(16), h: b.readUInt32BE(20), bytes };
45
+ if (b.toString("ascii", 0, 3) === "GIF") return { type: "gif", w: b.readUInt16LE(6), h: b.readUInt16LE(8), bytes };
46
+ if (b.toString("ascii", 8, 12) === "WEBP") {
47
+ const k = b.toString("ascii", 12, 16);
48
+ if (k === "VP8X") return { type: "webp", w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3), bytes };
49
+ if (k === "VP8L") { const v = b.readUInt32LE(21); return { type: "webp", w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1, bytes }; }
50
+ return { type: "webp", w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff, bytes };
51
+ }
52
+ if (b[0] === 0xff && b[1] === 0xd8) {
53
+ for (let i = 2; i < b.length - 9;) {
54
+ if (b[i] !== 0xff) { i++; continue; }
55
+ const m = b[i + 1], len = b.readUInt16BE(i + 2);
56
+ if (m >= 0xc0 && m <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(m)) return { type: "jpeg", w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5), bytes };
57
+ i += 2 + len;
58
+ }
59
+ return { type: "jpeg", w: 0, h: 0, bytes };
60
+ }
61
+ return { type: "unknown", w: 0, h: 0, bytes };
62
+ }
63
+
64
+ const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
65
+ /** A short name for a page: the last part of its path, or its host for a home page. */
66
+ const slug = (t) => {
67
+ const u = /^https?:\/\//.test(t) ? new URL(t) : null, last = (u ? u.pathname : t).split("/").filter(Boolean).pop();
68
+ return norm((last || u?.hostname || "page").replace(/\.[a-z0-9]+$/i, "")).replace(/ /g, "-").slice(0, 40) || "page";
69
+ };
70
+ /** The image's words (its alt, less the brand's name) found again in the og:title: a preview that says them twice. */
71
+ const repeats = (title, alt) => {
72
+ const words = (s) => norm(s).split(" ").filter((w) => w && !["wand", "ai", "a", "an", "the"].includes(w));
73
+ const t = new Set(words(title)), a = words(alt);
74
+ return a.length >= 3 && a.filter((w) => t.has(w)).length / a.length >= 0.8;
75
+ };
76
+
77
+ export async function audit(targets, { image = null, out = "audit" } = {}) {
78
+ if (!targets.length) throw new Error("usage: node wand.js social --audit <url | page.html> [more] [--image og-image.png] [--out audit]");
79
+ out = path.resolve(out); fs.mkdirSync(out, { recursive: true });
80
+ const rows = [], pages = [];
81
+ const row = (ok, gate, msg, warn = false) => rows.push({ ok, warn, gate, msg });
82
+ for (const t of targets) {
83
+ const id = slug(t), isUrl = /^https?:\/\//.test(t);
84
+ let html;
85
+ try {
86
+ if (isUrl) {
87
+ const r = await fetch(t, { signal: AbortSignal.timeout(15000), headers: { "user-agent": "Mozilla/5.0 (wand-kit link-preview audit)" } });
88
+ if (!r.ok) throw new Error(`answered ${r.status}`);
89
+ html = await r.text();
90
+ } else html = fs.readFileSync(path.resolve(t), "utf8");
91
+ } catch (e) {
92
+ row(false, id, `could not read ${t} from here (${e.cause?.code || e.message}). Save the page (File, Save As) and its og:image, then: --audit page.html --image image.png`);
93
+ continue;
94
+ }
95
+ const { tags, count, title } = readTags(html), p = { id, target: t, tags, title };
96
+ pages.push(p);
97
+ const want = ["og:title", "og:description", "og:url", "og:image", "og:image:width", "og:image:height", "og:image:type", "og:image:alt", "twitter:card", "twitter:title", "twitter:image"];
98
+ const missing = want.filter((k) => !tags[k]);
99
+ row(!!tags["og:image"] && !missing.length, `${id} tags`, tags["og:image"] ? (missing.length ? `missing ${missing.join(", ")}` : "every og: and twitter: tag a feed reads") : "no og:image: every feed shows a bare link", !!tags["og:image"] && missing.length > 0);
100
+ if ((count["twitter:card"] || 0) > 1) row(false, `${id} twitter:card`, `${count["twitter:card"]} twitter:card tags; Slack reads the first`, true);
101
+ const dash = Object.entries(tags).filter(([, v]) => /—/.test(v)).map(([k]) => k);
102
+ if (dash.length) row(false, `${id} dashes`, `em dash in ${dash.join(", ")}`, true);
103
+ if (repeats(tags["og:title"], tags["og:image:alt"])) row(false, `${id} title`, "og:title repeats the image's words, so the preview shows them twice", true);
104
+ // the image itself
105
+ const src = image && targets.length === 1 ? image : tags["og:image"] ? new URL(tags["og:image"], isUrl ? t : "https://example.com/").href : null;
106
+ if (tags["og:image"] && !/^https:\/\//.test(tags["og:image"])) row(false, `${id} image url`, `og:image is not an absolute https URL (${tags["og:image"]}); some feeds drop it`, true);
107
+ if (src) {
108
+ try {
109
+ p.png = await fetchTo(src, path.join(out, `live-${id}`), process.cwd());
110
+ const f = imageFacts(p.png); p.facts = f;
111
+ const ratio = f.h ? f.w / f.h : 0, notes = [];
112
+ if (f.bytes > LIMIT) notes.push("over 300 KB: WhatsApp shows no preview");
113
+ if (ratio && Math.abs(ratio / 1.905 - 1) > 0.05) notes.push(`${ratio.toFixed(2)}:1, not 1.91:1: feeds crop it`);
114
+ if (f.w && f.w < 1200) notes.push("under 1200 wide: soft on large cards");
115
+ row(!notes.length, `${id} image`, `${f.w} x ${f.h}, ${Math.round(f.bytes / 1024)} KB, ${f.type}${notes.length ? "; " + notes.join("; ") : ""}`, notes.length > 0);
116
+ } catch (e) { row(false, `${id} image`, `${e.message}`); }
117
+ }
118
+ }
119
+ // one picture for every page: nothing in it says what the link is
120
+ const imgs = pages.map((p) => p.tags["og:image"]).filter(Boolean);
121
+ if (pages.length > 1 && new Set(imgs).size < imgs.length) row(false, "pages", `${imgs.length - new Set(imgs).size + 1} pages share one og:image: each needs its own (an article its headline)`, true);
122
+
123
+ const shot = pages.filter((p) => p.png);
124
+ if (shot.length) {
125
+ const u = (f) => pathToFileURL(f).href;
126
+ const body = shot.map((p) => `<h2>${p.target}</h2>${feedRows(u(p.png), { title: p.tags["og:title"] || p.title, url: p.tags["og:url"] || p.target, description: p.tags["og:description"] })}${p.facts?.w ? wholeWithSquare(u(p.png), p.facts.w, p.facts.h) : ""}`).join("\n");
127
+ const pg = previewPage(body), htmlPath = path.join(out, "audit.html"), png = path.join(out, "audit.png");
128
+ fs.writeFileSync(htmlPath, pg.html);
129
+ const b = await browser(), v = await b.newPage({ viewport: { width: pg.w, height: 400 } });
130
+ await v.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
131
+ await v.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
132
+ await v.screenshot({ path: png, fullPage: true });
133
+ await v.close();
134
+ row(true, "preview", path.relative(process.cwd(), png));
135
+ }
136
+ const ok = rows.every((r) => r.ok || r.warn);
137
+ console.log("");
138
+ for (const r of rows) console.log(` ${r.ok ? " ok " : r.warn ? " -- " : "FAIL"} ${r.gate.padEnd(34)} ${r.msg}`);
139
+ if (shot.length) console.log(`\n review ${path.relative(process.cwd(), path.join(out, "audit.png"))}, as the people scrolling past will see it:\n does the dashed centre square (Slack's and WhatsApp's cut) hold the whole wordmark and the words?\n in LinkedIn's compact card (128pt), can you read the line? is there small print that vanishes?\n is the type the brand's, and does the image say what this page is?`);
140
+ console.log("");
141
+ return { ok, rows, pages };
142
+ }
package/lib/brand.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * lib/brand.js — the two brands, resolved and turned into CSS.
2
+ * lib/brand.js — the three brands, resolved and turned into CSS.
3
3
  *
4
4
  * A brand is a directory with `tokens.json` (swatch, roles, faces, shape, art),
5
5
  * `fonts/`, `assets/` and `lib/` (the Python helpers the exporters borrow).
6
6
  * The kit runs in two shapes and finds the directory in both:
7
7
  *
8
- * repo power-prompts/studio/kit/ -> ../../../brand and brand-next
8
+ * repo power-prompts/studio/kit/ -> ../../../brand, brand-next and brand-interim
9
9
  * packaged tools/package.mjs copies each into brands/<name>/ beside the kit
10
10
  *
11
11
  * `WAND_BRANDS_DIR` overrides both: it holds <name>/ per brand.
@@ -23,7 +23,7 @@ import path from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
 
25
25
  const KIT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
26
- const REPO_DIRS = { current: "brand", next: "brand-next" };
26
+ const REPO_DIRS = { current: "brand", next: "brand-next", interim: "brand-interim" };
27
27
  export const BRANDS = Object.keys(REPO_DIRS);
28
28
 
29
29
  // The brand a job gets when the user names none. While the two brands overlap
@@ -37,13 +37,13 @@ export const DEFAULT_BRAND = "current";
37
37
  export const ASKED_AS = {
38
38
  current: ["current brand", "old brand", "existing guidelines", "Geist"],
39
39
  next: ["new brand", "new branding", "new brand guidelines", "the website's look"],
40
+ interim: ["interim brand", "interim branding", "the live site's look", "the transitional website", "wand.ai as it is today"],
40
41
  };
41
42
 
42
43
  /** The default and the words that switch away from it, as one line. */
43
44
  export function brandRule() {
44
- const other = BRANDS.find((b) => b !== DEFAULT_BRAND);
45
- const words = ASKED_AS[other].map((w) => `"${w}"`);
46
- return `${DEFAULT_BRAND} by default; ${other} only when the user asks for ${words.slice(0, -1).join(", ")} or ${words.at(-1)}`;
45
+ const words = (b) => { const w = ASKED_AS[b].map((x) => `"${x}"`); return `${w.slice(0, -1).join(", ")} or ${w.at(-1)}`; };
46
+ return `${DEFAULT_BRAND} by default; ${BRANDS.filter((b) => b !== DEFAULT_BRAND).map((b) => `${b} only when the user asks for ${words(b)}`).join("; ")}`;
47
47
  }
48
48
 
49
49
  /** Absolute path of a brand directory, or an error naming every place looked. */
@@ -128,11 +128,16 @@ function brandCss(tokens, roles, fonts, extras = {}) {
128
128
  `@font-face{font-family:"${f.family}";font-weight:${f.weight};font-style:${f.italic ? "italic" : "normal"};src:url("${url(path.join(fonts, f.file))}")}`).join("\n");
129
129
  const F = tokens.faces, S = tokens.shape;
130
130
  const face = (k) => `--face-${k}:"${F[k].family}";--w-${k}:${F[k].weight};--tr-${k}:${F[k].tracking}em;--lh-${k}:${F[k].leading}`;
131
+ // a brand whose title phrase changes face (interim: Newsreader for <em>) declares `faces.phrase`; without it the
132
+ // phrase inherits the title's face, so the other brands draw exactly as before
133
+ // a brand may draw its art unshaded (`shape.artShade`, 0.3 otherwise) and set its cover title at its own tracking
134
+ // (`faces.display.coverTracking`, -0.03em otherwise): interim does both, as its approved social design does
135
+ const P = F.phrase, phrase = P ? `;--face-phrase:"${P.family}";--w-phrase:${P.weight};--tr-phrase:${P.tracking}em;--style-phrase:${P.italic ? "italic" : "normal"};--style-phrase-art:${P.artItalic || P.italic ? "italic" : "normal"}` : "";
131
136
  // the talk and board runtimes' own controls, dressed in the brand's dark roles
132
137
  const d = roles.dark, ui = { bg: d.track, card: d.paper, stroke: d.stroke, ink: d.ink, text: d.text, label: d.muted, accent: d.accent };
133
138
  return `${faces}
134
139
  :root{${Object.entries(ui).map(([k, v]) => `--ui-${k}:#${v}`).join(";")};--ui-face:"${F.body.family}"}
135
140
  .page{${vars(roles.light)};${Object.keys(extras).length ? vars(extras) + ";" : ""}${face("display")};${face("body")};${face("label")};--caps-label:${F.label.caps ? "uppercase" : "none"};
136
- --radius-k:${S.radius};--card-stroke:${S.cardStroke}px;--emph-stroke:${S.emphStroke}px}
141
+ --radius-k:${S.radius};--card-stroke:${S.cardStroke}px;--emph-stroke:${S.emphStroke}px;--art-shade:${S.artShade ?? 0.3};--tr-cover:${F.display.coverTracking ?? -0.03}em${phrase}}
137
142
  .page.dark{${vars(roles.dark)}}`;
138
143
  }
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
 
@@ -50,17 +51,20 @@ export function bind({ brand, surface, job, content, dir: jobDir = process.cwd()
50
51
  * number. `art` is a brand art role ("cover", "overlay", "closing") or a
51
52
  * file path; `notes` become speaker notes in the .pptx and the presenter
52
53
  * view; `seconds` is the slide's share of the talk, for the presenter's
53
- * pace; `motion: "none"` keeps a slide still when the deck animates.
54
+ * pace; `motion: "none"` keeps a slide still when the deck animates; `footer` replaces the job's footer line
55
+ * on this page (a source note).
54
56
  */
55
- const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "", autosize = true } = {}) => {
57
+ const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true, seconds = 0, motion = "", autosize = true, footer = null } = {}) => {
56
58
  n += 1;
57
- const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(art)) : null;
59
+ // a brand may draw a role again for a portrait page (`coverPortrait`), where its landscape art would crop away
60
+ const role = art && surface.h > surface.w && brand.tokens.art[`${art}Portrait`] ? `${art}Portrait` : art;
61
+ const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(role)) : null;
58
62
  const eb = job.tag && eyebrow ? `${esc(job.tag)}<span class="bar">|</span>${esc(eyebrow)}` : esc(job.tag || eyebrow || "");
59
63
  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
64
  ${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
61
65
  ${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
62
66
  <div class="content">${body}</div>
63
- ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${String(n).padStart(2, "0")}</span></footer>` : ""}
67
+ ${chrome ? `<footer><span>${esc(footer ?? job.footer ?? "")}</span><span class="pn">${String(n).padStart(2, "0")}</span></footer>` : ""}
64
68
  </section>`;
65
69
  };
66
70
 
@@ -130,13 +134,29 @@ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${Stri
130
134
  /** A big outlined numeral in a dark panel on the right (the deck's chapter pages). */
131
135
  const chapter = (num) => `<div class="chapter deco"><span>${esc(num)}</span></div>`;
132
136
 
137
+ /**
138
+ * A photograph, filling its box and given the brand's one grade: blacks lift toward the dark ground,
139
+ * highlights settle toward the light one, so photographs from different shoots read as one set.
140
+ * `src` is a photograph of the brands' set by name ("walk", "corridor"; `brand-next/assets/photos`, which
141
+ * the current brand shares, graded on its own grounds) or a path.
142
+ */
143
+ const photo = (src, { cls: k = "", alt = "", pos = "" } = {}) => {
144
+ let href = /^(file|https?|data):/.test(src) ? src : url(src);
145
+ if (!/[\/.]/.test(src)) {
146
+ const dirs = [brand.assets, ...BRANDS.filter((b) => b !== brand.name).map((b) => { try { return path.join(brandDir(b), "assets"); } catch { return null; } }).filter(Boolean)];
147
+ const hit = dirs.map((d) => path.join(d, "photos", `${src}.jpg`)).find((f) => fs.existsSync(f));
148
+ if (!hit) throw new Error(`photo("${src}"): no photograph of that name in ${dirs.map((d) => path.join(d, "photos")).join(" or ")}`);
149
+ href = url(hit);
150
+ }
151
+ return `<figure class="photo${cls(k)}"><img src="${href}" alt="${esc(alt)}"${pos ? ` style="object-position:${esc(pos)}"` : ""}><span class="grade"></span></figure>`;
152
+ };
133
153
  const img = (src, k = "", alt = "") => `<img class="img${cls(k)}" src="${/^(file|https?|data):/.test(src) ? src : url(src)}" alt="${esc(alt)}">`;
134
154
  const icon = (name, k = "") => brand.glyph(name).replace("<svg", `<svg class="icon deco${cls(k)}" aria-hidden="true"`);
135
155
  const tile = (name) => `<div class="tile deco">${icon(name)}</div>`;
136
156
 
137
157
  /* ---------------------------------------------------------- motion */
138
158
 
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. */
159
+ /** 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
160
  const enter = (kind = "", i = null) => ` data-a="${esc(kind)}"${i != null ? ` style="--i:${+i}"` : ""}`;
141
161
  /** Held back until the presenter moves on; steps of one number come in together. */
142
162
  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 +255,7 @@ ${DIR.some(([k]) => s[k]) ? `<dl class="direction">${DIR.filter(([k]) => s[k]).m
235
255
  page, sec, h1: h("h1"), h2: h("h2"), h3: h("h3"), h4: h("h4"), h5: h("h5"),
236
256
  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
257
  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,
258
+ cover, closing, chapter, img, photo, icon, tile, mark, wordmark,
239
259
  asset: brand.assetUrl, art: brand.artUrl,
240
260
  enter, step, countup, video, demo, sketch, storyboard,
241
261
  brand: brand.name, surface: surface.name, count: () => n,
@@ -249,7 +269,7 @@ html,body{background:#E9E7EE}
249
269
  .page{position:relative;overflow:hidden;break-after:page;background:var(--bg);color:var(--ink);font-family:var(--face-body);font-weight:var(--w-body);
250
270
  letter-spacing:var(--tr-body);line-height:var(--lh-body);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
251
271
  .art{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:38% 50%}
252
- .shade{position:absolute;inset:0;background:var(--bg);opacity:.3}
272
+ .shade{position:absolute;inset:0;background:var(--bg);opacity:var(--art-shade,.3)}
253
273
  .eyebrow{position:absolute;left:var(--x);top:var(--c-eyebrow-top);font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--c-eyebrow-size);
254
274
  letter-spacing:.22em;text-transform:var(--caps-label);color:var(--muted);line-height:1.2}
255
275
  .dark .eyebrow{color:var(--ink)}
@@ -265,7 +285,13 @@ footer .pn{font-weight:500}
265
285
  /* type */
266
286
  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
287
  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)}
288
+ /* a title's phrase is its second tone (on the current brand, the accent itself); on the cover, a statement
289
+ and the close, the pages that carry the brand's art, it keeps the accent. A brand may give the phrase its
290
+ own face (interim: Newsreader, the italic on the art pages); the others leave the variables unset */
291
+ h1 em,h2 em,.display em{font-family:var(--face-phrase,inherit);font-weight:var(--w-phrase,inherit);font-style:var(--style-phrase,normal);letter-spacing:var(--tr-phrase,inherit);color:var(--second)}
292
+ h3 em,h4 em,h5 em,.lede em{font-style:normal;color:var(--second-sm)}
293
+ .page:has(>.art) :is(h1,h2,h3,h4,h5,.display,.lede) em{color:var(--accent-text)}
294
+ .page:has(>.art) :is(h1,h2,.display) em{font-style:var(--style-phrase-art,normal)}
269
295
  .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
296
  .label.sm{font-size:calc(var(--t-label) * .8)}
271
297
  .lead{font-size:var(--t-lead);line-height:1.5;color:var(--ink)}
@@ -382,7 +408,7 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
382
408
  .cover .content{justify-content:center;gap:0}
383
409
  .cover.split .content{justify-content:space-between}
384
410
  .cover .top{display:flex;flex-direction:column;gap:1.3em}
385
- .cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing:-.03em}
411
+ .cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing:var(--tr-cover,-.03em)}
386
412
  .lede{font-size:var(--t-lede);font-weight:500;line-height:1.45;color:var(--ink);max-width:88%}
387
413
  .cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:min(80%,44em)}
388
414
  .meta{font-size:var(--t-meta);color:var(--text);padding-top:1em;border-top:1px solid var(--rule);line-height:1.4}
@@ -401,6 +427,12 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
401
427
  .has-chapter .left{width:62%;display:flex;flex-direction:column;gap:var(--m-gap)}
402
428
  /* images, icons */
403
429
  .img{display:block;max-width:100%}
430
+ .photo{position:relative;margin:0;overflow:hidden;border-radius:calc(var(--m-radius) * var(--radius-k));background:var(--grade-high)}
431
+ .photo>img{display:block;width:100%;height:100%;object-fit:cover}
432
+ .photo>.grade{position:absolute;inset:0;pointer-events:none}
433
+ .photo>.grade::before,.photo>.grade::after{content:"";position:absolute;inset:0}
434
+ .photo>.grade::before{background:var(--grade-low);mix-blend-mode:lighten;opacity:.42}
435
+ .photo>.grade::after{background:var(--grade-high);mix-blend-mode:darken;opacity:.55}
404
436
  .figure{border-radius:calc(var(--m-radius) * var(--radius-k));overflow:hidden}
405
437
  .icon{width:1.25em;height:1.25em;stroke:currentColor;fill:none}
406
438
  .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
@@ -97,7 +97,21 @@ const stem = (job, file) => (job.file || job.name || path.basename(path.dirname(
97
97
  /* ------------------------------------------------------------------ gates */
98
98
 
99
99
  /** Runs inside the page. Everything is read from the rendered DOM. */
100
- function GATES({ min, chars, tokens, families, base }) {
100
+ /* The lines a block of text sets in, read from the browser's own line boxes: a box's height misleads (a table cell
101
+ is as tall as its row, a card's paragraph as tall as the card), the fragments of its text do not. Runs inside the page. */
102
+ const LINES_OF = `(el) => {
103
+ const rg = document.createRange(); rg.selectNodeContents(el);
104
+ const rs = [...rg.getClientRects()].filter((q) => q.width > 0.5 && q.height > 0.5).sort((a, b) => a.top - b.top);
105
+ let n = 0, cur = null;
106
+ for (const q of rs) {
107
+ if (cur && q.top < cur.bottom - Math.min(q.height, cur.bottom - cur.top) * 0.5) { cur.bottom = Math.max(cur.bottom, q.bottom); continue; }
108
+ n++; cur = { top: q.top, bottom: q.bottom };
109
+ }
110
+ return n;
111
+ }`;
112
+
113
+ function GATES({ min, chars, tokens, families, base, maxLines = 0, linesOf }) {
114
+ const countLines = eval(linesOf);
101
115
  const T = new Set(tokens);
102
116
  const pages = [...document.querySelectorAll(".page")];
103
117
  const toHex = (c) => {
@@ -109,15 +123,32 @@ function GATES({ min, chars, tokens, families, base }) {
109
123
  const lum = (h) => { const c = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
110
124
  const contrast = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
111
125
  const bgOf = (el) => { for (let e = el; e; e = e.parentElement) { const h = toHex(getComputedStyle(e).backgroundColor); if (h) return h; if (e.classList.contains("page")) break; } return null; };
112
- const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], dash: [], texts: [], notes: [], broken: [],
126
+ const res = { fit: [], overlap: [], brand: [], fonts: [], contrast: [], type: [], measure: [], lines: [], dash: [], stretched: [], texts: [], notes: [], broken: [], extent: [],
113
127
  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
128
  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("/")); });
129
+ // an image drawn at another shape than its own (a flex column stretches an <img> with width:auto across it, the
130
+ // sales deck's screenshots on 2026-10-27): only object-fit cover, contain or none may change the box's shape
131
+ document.querySelectorAll("img").forEach((im) => {
132
+ if (!im.naturalWidth || !im.naturalHeight || /\.svg($|[?#])/i.test(im.getAttribute("src") || "")) return;
133
+ const r = im.getBoundingClientRect(); if (r.width < 4 || r.height < 4) return;
134
+ if (["cover", "contain", "none", "scale-down"].includes(getComputedStyle(im).objectFit)) return;
135
+ const k = (r.width / r.height) / (im.naturalWidth / im.naturalHeight);
136
+ if (Math.abs(k - 1) > 0.02) res.stretched.push({ page: pages.indexOf(im.closest(".page")) + 1, src: decodeURIComponent(im.getAttribute("src").replace(/^file:\/\//, "")).split("/").pop(), k: +k.toFixed(2) });
137
+ });
115
138
  const seen = new Set();
139
+ // a page's composition, coarsely: the blocks of its content in order, and how many a grid holds
140
+ 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(".") : ""); };
141
+ res.sig = pages.map((pg) => {
142
+ const c = pg.querySelector(".content");
143
+ if (!c || pg.matches(".cover, .back, .shot, .run")) return null;
144
+ 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(" ");
145
+ });
116
146
  pages.forEach((pg, pi) => {
117
147
  const n = pi + 1;
118
148
  const content = pg.querySelector(".content");
119
149
  const box = (content || pg).getBoundingClientRect();
120
- const foot = pg.querySelector("footer")?.getBoundingClientRect();
150
+ // a page that hides its footer (a cover, a close) is measured against its content box alone
151
+ const fr = pg.querySelector("footer")?.getBoundingClientRect(), foot = fr && fr.height ? fr : null;
121
152
  const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
122
153
  let over = 0, why = "", low = -Infinity;
123
154
  for (const el of (content || pg).querySelectorAll("*")) {
@@ -135,6 +166,9 @@ function GATES({ min, chars, tokens, families, base }) {
135
166
  const all = [...pg.querySelectorAll("*")];
136
167
  const leaves = all.filter((e) => !e.closest(".deco, svg, script, style") && [...e.childNodes].some((c) => c.nodeType === 3 && c.data.trim()));
137
168
  const boxes = leaves.map((e) => ({ e, r: e.getBoundingClientRect() })).filter((x) => x.r.width && x.r.height);
169
+ // how far the words run across the page, for a share image that a square crop from the centre must keep
170
+ const words = boxes.filter((x) => content && content.contains(x.e)), pgBox = pg.getBoundingClientRect();
171
+ if (words.length) res.extent.push({ page: n, l: Math.round(Math.min(...words.map((x) => x.r.left)) - pgBox.left), r: Math.round(Math.max(...words.map((x) => x.r.right)) - pgBox.left) });
138
172
  for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
139
173
  const A = boxes[i], B = boxes[j];
140
174
  if (A.e.contains(B.e) || B.e.contains(A.e)) continue;
@@ -163,6 +197,8 @@ function GATES({ min, chars, tokens, families, base }) {
163
197
  const r = el.getBoundingClientRect(), lh = parseFloat(cs.lineHeight) || size * 1.4;
164
198
  const inner = r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
165
199
  if (Math.round(inner / lh) >= 2 && r.width / (size * 0.48) > chars) res.measure.push({ page: n, el: sel(el), chars: Math.round(r.width / (size * 0.48)) });
200
+ // a paragraph that wraps past maxLines on a slide: the edits in PowerPoint and Google Slides reflow it, and it breaks
201
+ if (maxLines && cs.display !== "inline") { const k = countLines(el); if (k > maxLines) res.lines.push({ page: n, el: sel(el), lines: k, text: (el.innerText || "").trim().slice(0, 32) }); }
166
202
  }
167
203
  const text = pg.innerText || "";
168
204
  if (text.includes("\u2014")) res.dash.push(n);
@@ -178,7 +214,8 @@ function GATES({ min, chars, tokens, families, base }) {
178
214
  * sizes itself to its slides, the same way on every run. Returns the pages
179
215
  * it changed, and every slide's fill after.
180
216
  */
181
- function AUTOSIZE() {
217
+ function AUTOSIZE({ maxLines = 0, linesOf } = {}) {
218
+ const countLines = eval(linesOf);
182
219
  const fill = (pg) => {
183
220
  const c = pg.querySelector(".content"); if (!c) return 1;
184
221
  const kids = [...c.children].filter((k) => !k.classList.contains("deco") && k.getBoundingClientRect().height);
@@ -187,15 +224,20 @@ function AUTOSIZE() {
187
224
  return (kids.reduce((a, k) => a + k.getBoundingClientRect().height, 0) + gap * (kids.length - 1)) / c.getBoundingClientRect().height;
188
225
  };
189
226
  const overflows = (pg) => {
190
- const c = pg.querySelector(".content"), box = c.getBoundingClientRect(), foot = pg.querySelector("footer")?.getBoundingClientRect();
227
+ const c = pg.querySelector(".content"), box = c.getBoundingClientRect(), fr = pg.querySelector("footer")?.getBoundingClientRect(), foot = fr && fr.height ? fr : null;
191
228
  const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
192
229
  // as the fit gate reads it: an inline run's line box may rise above the content's top, a block may not
193
230
  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
231
  };
232
+ // the step up is also undone when it would wrap a paragraph past the job's line limit
233
+ const tooLong = (pg) => maxLines > 0 && [...pg.querySelectorAll(".content *")].some((el) => {
234
+ if (el.closest(".deco, svg") || ![...el.childNodes].some((n) => n.nodeType === 3 && n.data.trim())) return false;
235
+ return getComputedStyle(el).display !== "inline" && countLines(el) > maxLines;
236
+ });
195
237
  const sized = [], fills = [];
196
238
  document.querySelectorAll(".page").forEach((pg, i) => {
197
239
  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); }
240
+ if (fill(pg) < 0.55 && !pg.classList.contains("big")) { pg.classList.add("big"); if (overflows(pg) || tooLong(pg)) pg.classList.remove("big"); else sized.push(i + 1); }
199
241
  fills.push({ page: i + 1, fill: fill(pg) });
200
242
  });
201
243
  return { sized, fills };
@@ -238,10 +280,12 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
238
280
  await page.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
239
281
  await page.waitForTimeout(120);
240
282
 
283
+ // a slide's paragraphs stay at two lines unless the job says otherwise (Carlos, 2026-10-27: past two "it breaks a lot")
284
+ const maxLines = surface.kind === "slide" ? job.maxLines ?? 2 : job.maxLines ?? 0;
241
285
  // 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: [] };
286
+ const auto = surface.kind === "slide" ? await page.evaluate(AUTOSIZE, { maxLines, linesOf: LINES_OF }) : { sized: [], fills: [] };
243
287
  if (auto.sized.length) fs.writeFileSync(htmlPath, "<!doctype html>\n" + await page.evaluate(() => document.documentElement.outerHTML));
244
- const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base });
288
+ const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base, maxLines, linesOf: LINES_OF });
245
289
  const pageCount = g.fit.length;
246
290
  const text = g.texts.join("\n");
247
291
  const cov = content.has ? coverage(content, text, job.splitters) : null;
@@ -263,9 +307,26 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
263
307
  row(!deny.length, "names", job.denylist?.length ? (deny.length ? `found ${deny.join(", ")}` : `none of ${job.denylist.length} denied names`) : "no denylist", !job.denylist?.length);
264
308
  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);
265
309
  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);
310
+ // hard when the job sets maxLines, a warning on every other deck
311
+ if (maxLines) row(!g.lines.length, "lines", g.lines.length ? `${g.lines.length} over ${maxLines}: ` + g.lines.slice(0, 4).map((o) => `p${o.page} ${o.el} ${o.lines} lines (${o.text}…)`).join("; ") + (g.lines.length > 4 ? " …" : "") : `no paragraph over ${maxLines} line${maxLines === 1 ? "" : "s"}`, job.maxLines == null);
266
312
  const sparse = auto.fills.filter((f) => f.fill < 0.4);
267
313
  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);
268
- row(!g.broken.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}` : "every image loaded");
314
+ // the same composition page after page is what makes a set of pages read as a template
315
+ const runs = [], composed = !job.__board && surface.kind !== "card";
316
+ if (composed) for (let i = 1, start = 0; i <= g.sig.length; i++) {
317
+ if (i < g.sig.length && g.sig[i] && g.sig[i] === g.sig[i - 1]) continue;
318
+ if (i - start >= 3 && g.sig[start]) runs.push([start + 1, i]);
319
+ start = i;
320
+ }
321
+ 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);
322
+ // Slack's and WhatsApp's thumbnails of a link are a square cut from the image's centre: words outside it are cut
323
+ if (surface.name === "og") {
324
+ const sq = [(surface.w - surface.h) / 2, (surface.w + surface.h) / 2], cut = g.extent.filter((e) => e.l < sq[0] || e.r > sq[1]);
325
+ row(!cut.length, "crop", cut.length ? `${cut.map((e) => `p${e.page} words from x ${e.l} to ${e.r}`).join(", ")}; Slack and WhatsApp keep x ${sq[0]} to ${sq[1]}: keep the words of a share image there` : `every page's words inside the centre square (x ${sq[0]} to ${sq[1]}), whole in Slack's and WhatsApp's square cut`, true);
326
+ }
327
+ row(!g.broken.length && !g.stretched.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}`
328
+ : g.stretched.length ? `${g.stretched.length} drawn out of shape: ` + g.stretched.slice(0, 4).map((o) => `p${o.page} ${o.src} ${o.k > 1 ? "stretched" : "squeezed"} ${Math.round(Math.abs(o.k - 1) * 100)}%`).join("; ") + (g.stretched.length > 4 ? " …" : "")
329
+ : "every image loaded, each at its own shape");
269
330
  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
331
  const board = job.__board || null;
271
332
  if (surface.kind === "slide" && !board) row(true, "motion", `${job.talk?.motion === "none" ? "still" : "entrances on every slide"}${g.motion.a ? `, ${g.motion.a} marked` : ""}${g.motion.steps ? `, ${g.motion.steps} held for steps` : ""} (the HTML; static exports show the end state)`);
@@ -320,6 +381,8 @@ export function printReport(r) {
320
381
  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
382
  if (r.outputs.pptx) console.log(` ${path.relative(process.cwd(), r.outputs.pptx)} (${(fs.statSync(r.outputs.pptx).size / 1e6).toFixed(1)} MB)`);
322
383
  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)`);
384
+ // what no gate sees: read the sheet the way a creative director would, before anyone else does
385
+ 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
386
  console.log(r.ok ? "" : "\n FAILED — fix the rows marked FAIL\n");
324
387
  }
325
388