wand-kit 0.3.3 → 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 (159) hide show
  1. package/PROMPT.md +182 -16
  2. package/SKILL.md +91 -18
  3. package/START-HERE.md +12 -1
  4. package/brands/interim/assets/avatar.svg +1 -0
  5. package/brands/interim/assets/closing-art.png +0 -0
  6. package/brands/interim/assets/cover-art.png +0 -0
  7. package/brands/interim/assets/cover-portrait-art.png +0 -0
  8. package/brands/interim/assets/divider-art.png +0 -0
  9. package/brands/interim/assets/glyphs/README.md +29 -0
  10. package/brands/interim/assets/glyphs/activity.svg +15 -0
  11. package/brands/interim/assets/glyphs/atom.svg +17 -0
  12. package/brands/interim/assets/glyphs/badge-check.svg +16 -0
  13. package/brands/interim/assets/glyphs/bell.svg +16 -0
  14. package/brands/interim/assets/glyphs/blocks.svg +16 -0
  15. package/brands/interim/assets/glyphs/book-open.svg +16 -0
  16. package/brands/interim/assets/glyphs/bot.svg +20 -0
  17. package/brands/interim/assets/glyphs/boxes.svg +26 -0
  18. package/brands/interim/assets/glyphs/brain.svg +23 -0
  19. package/brands/interim/assets/glyphs/briefcase.svg +16 -0
  20. package/brands/interim/assets/glyphs/building-2.svg +21 -0
  21. package/brands/interim/assets/glyphs/calendar.svg +18 -0
  22. package/brands/interim/assets/glyphs/chart-column.svg +18 -0
  23. package/brands/interim/assets/glyphs/chart-line.svg +16 -0
  24. package/brands/interim/assets/glyphs/chart-pie.svg +16 -0
  25. package/brands/interim/assets/glyphs/circle-check.svg +16 -0
  26. package/brands/interim/assets/glyphs/clipboard-check.svg +17 -0
  27. package/brands/interim/assets/glyphs/clock.svg +16 -0
  28. package/brands/interim/assets/glyphs/cloud.svg +15 -0
  29. package/brands/interim/assets/glyphs/code.svg +15 -0
  30. package/brands/interim/assets/glyphs/coins.svg +18 -0
  31. package/brands/interim/assets/glyphs/compass.svg +16 -0
  32. package/brands/interim/assets/glyphs/cpu.svg +24 -0
  33. package/brands/interim/assets/glyphs/database.svg +17 -0
  34. package/brands/interim/assets/glyphs/eye.svg +16 -0
  35. package/brands/interim/assets/glyphs/file-check.svg +17 -0
  36. package/brands/interim/assets/glyphs/filter.svg +15 -0
  37. package/brands/interim/assets/glyphs/fingerprint.svg +23 -0
  38. package/brands/interim/assets/glyphs/flag.svg +16 -0
  39. package/brands/interim/assets/glyphs/gauge.svg +16 -0
  40. package/brands/interim/assets/glyphs/gavel.svg +19 -0
  41. package/brands/interim/assets/glyphs/git-branch.svg +18 -0
  42. package/brands/interim/assets/glyphs/globe.svg +17 -0
  43. package/brands/interim/assets/glyphs/graduation-cap.svg +17 -0
  44. package/brands/interim/assets/glyphs/handshake.svg +19 -0
  45. package/brands/interim/assets/glyphs/hexagon.svg +15 -0
  46. package/brands/interim/assets/glyphs/index.json +177 -0
  47. package/brands/interim/assets/glyphs/infinity.svg +15 -0
  48. package/brands/interim/assets/glyphs/key.svg +17 -0
  49. package/brands/interim/assets/glyphs/landmark.svg +20 -0
  50. package/brands/interim/assets/glyphs/layers.svg +17 -0
  51. package/brands/interim/assets/glyphs/lightbulb.svg +17 -0
  52. package/brands/interim/assets/glyphs/link.svg +16 -0
  53. package/brands/interim/assets/glyphs/list-checks.svg +19 -0
  54. package/brands/interim/assets/glyphs/lock.svg +16 -0
  55. package/brands/interim/assets/glyphs/map.svg +17 -0
  56. package/brands/interim/assets/glyphs/megaphone.svg +16 -0
  57. package/brands/interim/assets/glyphs/message-square.svg +15 -0
  58. package/brands/interim/assets/glyphs/milestone.svg +17 -0
  59. package/brands/interim/assets/glyphs/network.svg +19 -0
  60. package/brands/interim/assets/glyphs/orbit.svg +19 -0
  61. package/brands/interim/assets/glyphs/package.svg +18 -0
  62. package/brands/interim/assets/glyphs/plug.svg +18 -0
  63. package/brands/interim/assets/glyphs/puzzle.svg +15 -0
  64. package/brands/interim/assets/glyphs/radar.svg +22 -0
  65. package/brands/interim/assets/glyphs/receipt.svg +17 -0
  66. package/brands/interim/assets/glyphs/refresh-cw.svg +18 -0
  67. package/brands/interim/assets/glyphs/rocket.svg +18 -0
  68. package/brands/interim/assets/glyphs/route.svg +17 -0
  69. package/brands/interim/assets/glyphs/scale.svg +19 -0
  70. package/brands/interim/assets/glyphs/search.svg +16 -0
  71. package/brands/interim/assets/glyphs/server.svg +18 -0
  72. package/brands/interim/assets/glyphs/settings.svg +16 -0
  73. package/brands/interim/assets/glyphs/share-2.svg +19 -0
  74. package/brands/interim/assets/glyphs/shield-check.svg +16 -0
  75. package/brands/interim/assets/glyphs/shuffle.svg +19 -0
  76. package/brands/interim/assets/glyphs/sparkles.svg +19 -0
  77. package/brands/interim/assets/glyphs/square-stack.svg +17 -0
  78. package/brands/interim/assets/glyphs/target.svg +17 -0
  79. package/brands/interim/assets/glyphs/trending-up.svg +16 -0
  80. package/brands/interim/assets/glyphs/triangle-alert.svg +17 -0
  81. package/brands/interim/assets/glyphs/trophy.svg +20 -0
  82. package/brands/interim/assets/glyphs/users.svg +18 -0
  83. package/brands/interim/assets/glyphs/wallet.svg +16 -0
  84. package/brands/interim/assets/glyphs/wand-sparkles.svg +22 -0
  85. package/brands/interim/assets/glyphs/workflow.svg +17 -0
  86. package/brands/interim/assets/glyphs/zap.svg +15 -0
  87. package/brands/interim/assets/mark.svg +1 -0
  88. package/brands/interim/assets/social/composition.json +6833 -0
  89. package/brands/interim/assets/spark-lavender.png +0 -0
  90. package/brands/interim/assets/spark-violet.png +0 -0
  91. package/brands/interim/assets/statement-art.png +0 -0
  92. package/brands/interim/assets/story/agent-alpha.png +0 -0
  93. package/brands/interim/assets/story/agent.png +0 -0
  94. package/brands/interim/assets/story/economy-alpha.png +0 -0
  95. package/brands/interim/assets/story/economy.png +0 -0
  96. package/brands/interim/assets/story/index.json +214 -0
  97. package/brands/interim/assets/story/learning-alpha.png +0 -0
  98. package/brands/interim/assets/story/learning.png +0 -0
  99. package/brands/interim/assets/story/organization-alpha.png +0 -0
  100. package/brands/interim/assets/story/organization.png +0 -0
  101. package/brands/interim/assets/story/worker-alpha.png +0 -0
  102. package/brands/interim/assets/story/worker-light.png +0 -0
  103. package/brands/interim/assets/story/worker.png +0 -0
  104. package/brands/interim/assets/wordmark-on-dark.png +0 -0
  105. package/brands/interim/assets/wordmark-on-dark.svg +1 -0
  106. package/brands/interim/assets/wordmark-on-light.png +0 -0
  107. package/brands/interim/assets/wordmark-on-light.svg +1 -0
  108. package/brands/interim/fonts/Geist-Book.ttf +0 -0
  109. package/brands/interim/fonts/Geist-Medium.ttf +0 -0
  110. package/brands/interim/fonts/Geist-Regular.ttf +0 -0
  111. package/brands/interim/fonts/Geist-SemiBold.ttf +0 -0
  112. package/brands/interim/fonts/Newsreader-Italic.ttf +0 -0
  113. package/brands/interim/fonts/Newsreader-Medium.ttf +0 -0
  114. package/brands/interim/fonts/Newsreader-MediumItalic.ttf +0 -0
  115. package/brands/interim/fonts/Newsreader-Regular.ttf +0 -0
  116. package/brands/interim/fonts/OFL-Geist.txt +93 -0
  117. package/brands/interim/fonts/OFL-Newsreader.txt +93 -0
  118. package/brands/interim/fonts/faces.json +12 -0
  119. package/brands/interim/lib/composition.js +169 -0
  120. package/brands/interim/lib/composition.mjs +101 -0
  121. package/brands/interim/lib/glyphs.js +89 -0
  122. package/brands/interim/lib/measure.js +121 -0
  123. package/brands/interim/lib/pdf.js +597 -0
  124. package/brands/interim/lib/ttf.py +169 -0
  125. package/brands/interim/lib/wandxml.py +37 -0
  126. package/brands/interim/tokens.json +299 -0
  127. package/examples/social/social.json +23 -0
  128. package/lib/audit.js +142 -0
  129. package/lib/brand.js +12 -7
  130. package/lib/components.js +13 -8
  131. package/lib/engine.js +55 -9
  132. package/lib/library.js +180 -0
  133. package/lib/pptx.js +1 -1
  134. package/lib/social.js +366 -0
  135. package/library/index.json +629 -0
  136. package/library/interim/avatar-800x800.png +0 -0
  137. package/library/interim/icons/apple-touch-icon.png +0 -0
  138. package/library/interim/icons/favicon-16.png +0 -0
  139. package/library/interim/icons/favicon-32.png +0 -0
  140. package/library/interim/icons/favicon-48.png +0 -0
  141. package/library/interim/icons/favicon.ico +0 -0
  142. package/library/interim/icons/favicon.svg +1 -0
  143. package/library/interim/icons/icon-192.png +0 -0
  144. package/library/interim/icons/icon-512.png +0 -0
  145. package/library/interim/icons/icon-tags.html +5 -0
  146. package/library/interim/icons/site.webmanifest +19 -0
  147. package/library/interim/linkedin-banner-1584x396.png +0 -0
  148. package/library/interim/linkedin-company-1128x191.png +0 -0
  149. package/library/interim/open-agent-discovery-share-1200x630.png +0 -0
  150. package/library/interim/open-agent-discovery-share-head-tags.html +17 -0
  151. package/library/interim/wand-ai-share-1200x630.png +0 -0
  152. package/library/interim/wand-ai-share-head-tags.html +17 -0
  153. package/library/interim/x-header-1500x500.png +0 -0
  154. package/package.json +2 -2
  155. package/templates/card.mjs +6 -2
  156. package/templates/page.mjs +1 -1
  157. package/templates/slide.mjs +1 -1
  158. package/templates/storyboard.mjs +1 -1
  159. package/wand.js +64 -5
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
@@ -51,17 +51,20 @@ export function bind({ brand, surface, job, content, dir: jobDir = process.cwd()
51
51
  * number. `art` is a brand art role ("cover", "overlay", "closing") or a
52
52
  * file path; `notes` become speaker notes in the .pptx and the presenter
53
53
  * view; `seconds` is the slide's share of the talk, for the presenter's
54
- * 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).
55
56
  */
56
- 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 } = {}) => {
57
58
  n += 1;
58
- 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;
59
62
  const eb = job.tag && eyebrow ? `${esc(job.tag)}<span class="bar">|</span>${esc(eyebrow)}` : esc(job.tag || eyebrow || "");
60
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"'}>
61
64
  ${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
62
65
  ${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
63
66
  <div class="content">${body}</div>
64
- ${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>` : ""}
65
68
  </section>`;
66
69
  };
67
70
 
@@ -266,7 +269,7 @@ html,body{background:#E9E7EE}
266
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);
267
270
  letter-spacing:var(--tr-body);line-height:var(--lh-body);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
268
271
  .art{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:38% 50%}
269
- .shade{position:absolute;inset:0;background:var(--bg);opacity:.3}
272
+ .shade{position:absolute;inset:0;background:var(--bg);opacity:var(--art-shade,.3)}
270
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);
271
274
  letter-spacing:.22em;text-transform:var(--caps-label);color:var(--muted);line-height:1.2}
272
275
  .dark .eyebrow{color:var(--ink)}
@@ -283,10 +286,12 @@ footer .pn{font-weight:500}
283
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)}
284
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}
285
288
  /* 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)}
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)}
288
292
  h3 em,h4 em,h5 em,.lede em{font-style:normal;color:var(--second-sm)}
289
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)}
290
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}
291
296
  .label.sm{font-size:calc(var(--t-label) * .8)}
292
297
  .lead{font-size:var(--t-lead);line-height:1.5;color:var(--ink)}
@@ -403,7 +408,7 @@ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums
403
408
  .cover .content{justify-content:center;gap:0}
404
409
  .cover.split .content{justify-content:space-between}
405
410
  .cover .top{display:flex;flex-direction:column;gap:1.3em}
406
- .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)}
407
412
  .lede{font-size:var(--t-lede);font-weight:500;line-height:1.45;color:var(--ink);max-width:88%}
408
413
  .cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:min(80%,44em)}
409
414
  .meta{font-size:var(--t-meta);color:var(--text);padding-top:1em;border-top:1px solid var(--rule);line-height:1.4}
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,9 +123,18 @@ 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();
116
139
  // a page's composition, coarsely: the blocks of its content in order, and how many a grid holds
117
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(".") : ""); };
@@ -124,7 +147,8 @@ function GATES({ min, chars, tokens, families, base }) {
124
147
  const n = pi + 1;
125
148
  const content = pg.querySelector(".content");
126
149
  const box = (content || pg).getBoundingClientRect();
127
- 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;
128
152
  const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
129
153
  let over = 0, why = "", low = -Infinity;
130
154
  for (const el of (content || pg).querySelectorAll("*")) {
@@ -142,6 +166,9 @@ function GATES({ min, chars, tokens, families, base }) {
142
166
  const all = [...pg.querySelectorAll("*")];
143
167
  const leaves = all.filter((e) => !e.closest(".deco, svg, script, style") && [...e.childNodes].some((c) => c.nodeType === 3 && c.data.trim()));
144
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) });
145
172
  for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
146
173
  const A = boxes[i], B = boxes[j];
147
174
  if (A.e.contains(B.e) || B.e.contains(A.e)) continue;
@@ -170,6 +197,8 @@ function GATES({ min, chars, tokens, families, base }) {
170
197
  const r = el.getBoundingClientRect(), lh = parseFloat(cs.lineHeight) || size * 1.4;
171
198
  const inner = r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
172
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) }); }
173
202
  }
174
203
  const text = pg.innerText || "";
175
204
  if (text.includes("\u2014")) res.dash.push(n);
@@ -185,7 +214,8 @@ function GATES({ min, chars, tokens, families, base }) {
185
214
  * sizes itself to its slides, the same way on every run. Returns the pages
186
215
  * it changed, and every slide's fill after.
187
216
  */
188
- function AUTOSIZE() {
217
+ function AUTOSIZE({ maxLines = 0, linesOf } = {}) {
218
+ const countLines = eval(linesOf);
189
219
  const fill = (pg) => {
190
220
  const c = pg.querySelector(".content"); if (!c) return 1;
191
221
  const kids = [...c.children].filter((k) => !k.classList.contains("deco") && k.getBoundingClientRect().height);
@@ -194,15 +224,20 @@ function AUTOSIZE() {
194
224
  return (kids.reduce((a, k) => a + k.getBoundingClientRect().height, 0) + gap * (kids.length - 1)) / c.getBoundingClientRect().height;
195
225
  };
196
226
  const overflows = (pg) => {
197
- 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;
198
228
  const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
199
229
  // as the fit gate reads it: an inline run's line box may rise above the content's top, a block may not
200
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;
201
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
+ });
202
237
  const sized = [], fills = [];
203
238
  document.querySelectorAll(".page").forEach((pg, i) => {
204
239
  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); }
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); }
206
241
  fills.push({ page: i + 1, fill: fill(pg) });
207
242
  });
208
243
  return { sized, fills };
@@ -245,10 +280,12 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
245
280
  await page.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
246
281
  await page.waitForTimeout(120);
247
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;
248
285
  // 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: [] };
286
+ const auto = surface.kind === "slide" ? await page.evaluate(AUTOSIZE, { maxLines, linesOf: LINES_OF }) : { sized: [], fills: [] };
250
287
  if (auto.sized.length) fs.writeFileSync(htmlPath, "<!doctype html>\n" + await page.evaluate(() => document.documentElement.outerHTML));
251
- 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 });
252
289
  const pageCount = g.fit.length;
253
290
  const text = g.texts.join("\n");
254
291
  const cov = content.has ? coverage(content, text, job.splitters) : null;
@@ -270,6 +307,8 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
270
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);
271
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);
272
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);
273
312
  const sparse = auto.fills.filter((f) => f.fill < 0.4);
274
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);
275
314
  // the same composition page after page is what makes a set of pages read as a template
@@ -280,7 +319,14 @@ export async function build(jobPath, { outputs: wantOutputs = true, outDir = nul
280
319
  start = i;
281
320
  }
282
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);
283
- row(!g.broken.length, "images", g.broken.length ? `${g.broken.length} did not load: ${g.broken.slice(0, 3).join(", ")}` : "every image loaded");
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");
284
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`);
285
331
  const board = job.__board || null;
286
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)`);
package/lib/library.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * lib/library.js — approved Wand files, handed out as they are.
3
+ *
4
+ * `library/index.json` lists every file a person approved: the social set on
5
+ * the interim brand (banners, avatar, the share images that went live, the
6
+ * site's icons), and the brands' own logos and art, which it names inside the
7
+ * brand folders rather than copying. Asked for "the LinkedIn banner", a chat
8
+ * hands over that file, byte for byte; it never redraws or re-exports one.
9
+ *
10
+ * node wand.js library [words] [--brand interim] what there is
11
+ * node wand.js library get <id> [<id>…] [--to dir] copy it, with where it goes
12
+ * node wand.js library --check every file is the one approved, and the kit still draws it
13
+ *
14
+ * index.json:
15
+ * recipes { <brand>: a social.json spec } how the kit draws the files it can draw
16
+ * assets [{ id, brand, kind, title, size, use, approved, recipe?, tags, files: [file] }]
17
+ * file { path, as?, from?, delivered?, tolerance?, px, bytes, sha256 }
18
+ * path inside library/, or "brand:<name>/<path in the brand folder>"
19
+ * as the name it is handed out under (a brand file: wand-<brand>-<name>)
20
+ * from the recipe's output it is checked against ("#tags": the card's tag block)
21
+ * delivered where the stored bytes came from, when they are a file people already received
22
+ * tolerance what the redraw may differ by: { mean (levels in 255, default 1), off (% of pixels
23
+ * over 24 levels, default 0.25) }; past 4 levels or 2% it fails
24
+ * `tools/library.mjs` (developer-only, in the repo) adds a file and rewrites bytes, sha256 and px.
25
+ */
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import crypto from "node:crypto";
30
+ import { fileURLToPath } from "node:url";
31
+ import { brandDir, DEFAULT_BRAND } from "./brand.js";
32
+
33
+ const KIT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
34
+ export const LIB = path.join(KIT, "library");
35
+
36
+ export const load = () => JSON.parse(fs.readFileSync(path.join(LIB, "index.json"), "utf8"));
37
+
38
+ /** The file on disk for an index path. */
39
+ export function resolve(p) {
40
+ const m = /^brand:([a-z]+)\/(.+)$/.exec(p);
41
+ return m ? path.join(brandDir(m[1]), m[2]) : path.join(LIB, p);
42
+ }
43
+
44
+ /** The name a file is handed out under. */
45
+ export function handOutName(file) {
46
+ if (file.as) return file.as;
47
+ const m = /^brand:([a-z]+)\/(.+)$/.exec(file.path);
48
+ return m ? `wand-${m[1]}-${path.basename(m[2])}` : path.basename(file.path);
49
+ }
50
+
51
+ export const sha256 = (f) => crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex");
52
+
53
+ /** Pixel size of a PNG, an ICO's largest frame or an SVG's viewBox; null for anything else. */
54
+ export function px(f) {
55
+ const b = fs.readFileSync(f);
56
+ if (b[0] === 0x89 && b.toString("ascii", 1, 4) === "PNG") return `${b.readUInt32BE(16)} x ${b.readUInt32BE(20)}`;
57
+ if (b.readUInt16LE(0) === 0 && b.readUInt16LE(2) === 1) { let m = 0; for (let i = 0; i < b.readUInt16LE(4); i++) m = Math.max(m, b[6 + 16 * i] || 256); return `${m} x ${m}`; }
58
+ const vb = /viewBox="[-\d.]+ [-\d.]+ ([\d.]+) ([\d.]+)"/.exec(b.toString("utf8", 0, 600));
59
+ return vb ? `${+vb[1]} x ${+vb[2]} (vector)` : null;
60
+ }
61
+
62
+ /** Assets whose id, title, kind, brand or tags hold every word. */
63
+ export function list(words = [], { brand = null } = {}) {
64
+ const ws = words.map((w) => w.toLowerCase());
65
+ return load().assets.filter((a) => (!brand || a.brand === brand) &&
66
+ ws.every((w) => [a.id, a.title, a.kind, a.brand, ...(a.tags || [])].join(" ").toLowerCase().includes(w)));
67
+ }
68
+
69
+ /** One asset by id; a bare name ("wordmark") resolves to the brand asked for, or the default brand's. */
70
+ export function find(id, { brand = null } = {}) {
71
+ const all = load().assets, exact = all.find((a) => a.id === id);
72
+ if (exact) return { asset: exact };
73
+ const cands = all.filter((a) => a.id.startsWith(id + "-") || a.id === `${id}-${brand || DEFAULT_BRAND}`);
74
+ const pick = cands.filter((a) => a.brand === (brand || DEFAULT_BRAND));
75
+ if (pick.length === 1) return { asset: pick[0] };
76
+ if (cands.length === 1) return { asset: cands[0] };
77
+ // not an id: the assets the word finds, for the chat to choose from
78
+ return { asset: null, cands: cands.length ? cands : list(id.split(/[\s-]+/), { brand }) };
79
+ }
80
+
81
+ /** Copy the assets' files into `to`; returns what was copied, for the note the chat gives with them. */
82
+ export function get(ids, { to = "wand-assets", brand = null } = {}) {
83
+ const done = [], missing = [];
84
+ for (const id of ids) {
85
+ const { asset, cands } = find(id, { brand });
86
+ if (!asset) { missing.push({ id, cands: (cands || []).map((a) => a.id) }); continue; }
87
+ const files = asset.files.map((f) => {
88
+ const src = resolve(f.path), dest = path.join(to, f.path.startsWith("brand:") ? "" : path.dirname(f.path).split(path.sep).slice(1).join(path.sep), handOutName(f));
89
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
90
+ fs.copyFileSync(src, dest);
91
+ if (f.sha256 && sha256(dest) !== f.sha256) throw new Error(`${dest}: does not match the approved file (run node wand.js library --check)`);
92
+ return { dest, px: f.px, bytes: f.bytes };
93
+ });
94
+ done.push({ asset, files });
95
+ }
96
+ return { done, missing };
97
+ }
98
+
99
+ /** The PNG inside each of an .ico's frames. */
100
+ function icoFrames(f) {
101
+ const b = fs.readFileSync(f), n = b.readUInt16LE(4);
102
+ return Array.from({ length: n }, (_, i) => { const o = 6 + 16 * i, len = b.readUInt32LE(o + 8), at = b.readUInt32LE(o + 12); return b.subarray(at, at + len); });
103
+ }
104
+
105
+ /** A browser page that diffs two images (paths or PNG buffers): mean and largest difference, levels in 255, and the share of pixels visibly off. */
106
+ async function differ(b) {
107
+ const page = await b.newPage();
108
+ const url = (f) => `data:image/png;base64,${(Buffer.isBuffer(f) ? f : fs.readFileSync(f)).toString("base64")}`;
109
+ const diff = (a, c) => page.evaluate(async ([da, dc]) => {
110
+ const load = (s) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = () => no(new Error("could not decode")); i.src = s; });
111
+ const [A, C] = await Promise.all([load(da), load(dc)]);
112
+ if (A.naturalWidth !== C.naturalWidth || A.naturalHeight !== C.naturalHeight) return { size: `${A.naturalWidth} x ${A.naturalHeight} against ${C.naturalWidth} x ${C.naturalHeight}` };
113
+ const data = (im) => { const cv = document.createElement("canvas"); cv.width = im.naturalWidth; cv.height = im.naturalHeight; const x = cv.getContext("2d"); x.drawImage(im, 0, 0); return x.getImageData(0, 0, cv.width, cv.height).data; };
114
+ const p = data(A), q = data(C); let sum = 0, max = 0, off = 0;
115
+ for (let i = 0; i < p.length; i += 4) {
116
+ let px = 0;
117
+ for (let c = 0; c < 4; c++) { const d = Math.abs(p[i + c] - q[i + c]); sum += d; if (d > px) px = d; }
118
+ if (px > max) max = px; if (px > 24) off++;
119
+ }
120
+ return { mean: sum / p.length, max, off: (100 * off * 4) / p.length };
121
+ }, [url(a), url(c)]);
122
+ return { diff, close: () => page.close() };
123
+ }
124
+
125
+ /**
126
+ * Every file present and the one approved (its sha256), then every file the kit can draw drawn again from the
127
+ * recipes and compared: within its tolerance it is ok; up to 4 levels a warning to look at; past that a failure,
128
+ * because the kit no longer draws what was approved (a regression, or a design change that needs approving).
129
+ */
130
+ export async function check({ quiet = false } = {}) {
131
+ const idx = load(), rows = [];
132
+ const row = (ok, gate, msg, warn = false) => rows.push({ ok, warn, gate, msg });
133
+ for (const a of idx.assets) {
134
+ const bad = a.files.filter((f) => { const p = resolve(f.path); return !fs.existsSync(p) || sha256(p) !== f.sha256; });
135
+ row(!bad.length, a.id, bad.length ? `not the approved file: ${bad.map((f) => f.path).join(", ")}` : `${a.files.length} file${a.files.length === 1 ? "" : "s"}, as approved`);
136
+ }
137
+ // the redraw: one social job per brand that has recipes, into a temp folder, compared file by file
138
+ const { social } = await import("./social.js");
139
+ const { browser } = await import("./engine.js");
140
+ for (const [brand, spec] of Object.entries(idx.recipes || {})) {
141
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `wand-library-${brand}-`));
142
+ const r = await social({ ...spec, brand, name: `library-${brand}` }, { dir, zip: false, quiet: true });
143
+ if (!r.ok) { row(false, `redraw ${brand}`, r.rows.filter((x) => !x.ok && !x.warn).map((x) => `${x.gate}: ${x.msg}`).join("; ")); continue; }
144
+ const out = r.out, byId = Object.fromEntries(r.images.map((i) => [i.id, i]));
145
+ const d = await differ(await browser());
146
+ for (const a of idx.assets.filter((x) => x.brand === brand && x.recipe)) {
147
+ const img = byId[a.recipe];
148
+ if (!img) { row(false, `redraw ${a.id}`, `no card "${a.recipe}" in the ${brand} recipes`); continue; }
149
+ const notes = [];
150
+ let worst = 0, worstOff = 0, failed = false, warned = false;
151
+ for (const f of a.files.filter((x) => x.from)) {
152
+ const mine = resolve(f.path);
153
+ if (f.from === "#tags") { if (fs.readFileSync(mine, "utf8") !== img.tags) { failed = true; notes.push(`${path.basename(f.path)} differs from the tags the kit writes`); } continue; }
154
+ const theirs = path.join(out, f.from);
155
+ if (!fs.existsSync(theirs)) { failed = true; notes.push(`the kit drew no ${f.from}`); continue; }
156
+ // an .ico holds PNGs, whose bytes differ between browser builds even where their pixels do not: compare each frame
157
+ const pairs = /\.ico$/i.test(f.from) ? icoFrames(mine).map((a, i) => [a, icoFrames(theirs)[i]]) : /\.png$/i.test(f.from) ? [[mine, theirs]] : null;
158
+ if (!pairs) { if (fs.readFileSync(mine, "utf8") !== fs.readFileSync(theirs, "utf8")) { failed = true; notes.push(`${f.from} differs`); } continue; }
159
+ if (pairs.some(([, b]) => !b)) { failed = true; notes.push(`${f.from}: not the same frames`); continue; }
160
+ const each = await Promise.all(pairs.map(([a, b]) => d.diff(a, b)));
161
+ const r2 = each.find((x) => x.size) || { mean: Math.max(...each.map((x) => x.mean)), off: Math.max(...each.map((x) => x.off)) };
162
+ if (r2.size) { failed = true; notes.push(`${f.from}: ${r2.size}`); continue; }
163
+ // a mean alone hides a moved line of type in a large image; the share of pixels visibly off (over 24 levels) does not
164
+ const tol = { mean: 1, off: 0.25, ...(f.tolerance || {}) }, said = `${f.from}: ${r2.mean.toFixed(2)} levels, ${r2.off.toFixed(2)}% of pixels visibly off`;
165
+ worst = Math.max(worst, r2.mean); worstOff = Math.max(worstOff, r2.off);
166
+ if (r2.mean > 4 || r2.off > 2) { failed = true; notes.push(said); }
167
+ else if (r2.mean > tol.mean || r2.off > tol.off) { warned = true; notes.push(`${said} (allowed ${tol.mean}, ${tol.off}%)`); }
168
+ }
169
+ row(!failed && !warned, `redraw ${a.id}`, failed || warned ? `${notes.join("; ")}; the kit's redraw is in ${out}` : `the kit draws it within ${worst.toFixed(2)} levels in 255, ${worstOff.toFixed(2)}% of pixels visibly off`, warned && !failed);
170
+ }
171
+ await d.close();
172
+ }
173
+ const ok = rows.every((r) => r.ok || r.warn);
174
+ if (!quiet) {
175
+ console.log("");
176
+ for (const r of rows) console.log(` ${r.ok ? " ok " : r.warn ? " -- " : "FAIL"} ${r.gate.padEnd(34)} ${r.msg}`);
177
+ console.log(`\n ${idx.assets.length} assets, ${idx.assets.reduce((n, a) => n + a.files.length, 0)} files${ok ? "" : "\n\n FAILED — a file is not the one approved, or the kit no longer draws it"}\n`);
178
+ }
179
+ return { ok, rows };
180
+ }
package/lib/pptx.js CHANGED
@@ -238,7 +238,7 @@ export async function exportPptx({ htmlPath, outPath, brand: brandName, surface,
238
238
 
239
239
  const msg = `${pages.length} slide${pages.length === 1 ? "" : "s"}, ${lines} text lines${links ? `, ${links} links` : ""}, ${fonts}`;
240
240
  if (missing.length) return { ok: false, warn: false, msg: `${missing.length} of ${lines} lines not verbatim in the XML: ${missing.slice(0, 3).join("; ")}` };
241
- return { ok: true, warn, msg };
241
+ return { ok: !warn, warn, msg };
242
242
  }
243
243
 
244
244
  /** A block of several lines (a title, a paragraph) moves as one: a group. */