wand-kit 0.0.0-stage → 0.2.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 (262) hide show
  1. package/.gitkeep +0 -0
  2. package/PROMPT.md +255 -0
  3. package/SKILL.md +117 -0
  4. package/START-HERE.md +16 -0
  5. package/brands/current/assets/bg-cover-dark.png +0 -0
  6. package/brands/current/assets/bg-overlay-dark.png +0 -0
  7. package/brands/current/assets/bg-statement-dark.png +0 -0
  8. package/brands/current/assets/closing-art.png +0 -0
  9. package/brands/current/assets/cover-art.png +0 -0
  10. package/brands/current/assets/divider-art.png +0 -0
  11. package/brands/current/assets/glyphs/README.md +29 -0
  12. package/brands/current/assets/glyphs/activity.svg +15 -0
  13. package/brands/current/assets/glyphs/atom.svg +17 -0
  14. package/brands/current/assets/glyphs/badge-check.svg +16 -0
  15. package/brands/current/assets/glyphs/bell.svg +16 -0
  16. package/brands/current/assets/glyphs/blocks.svg +16 -0
  17. package/brands/current/assets/glyphs/book-open.svg +16 -0
  18. package/brands/current/assets/glyphs/bot.svg +20 -0
  19. package/brands/current/assets/glyphs/boxes.svg +26 -0
  20. package/brands/current/assets/glyphs/brain.svg +23 -0
  21. package/brands/current/assets/glyphs/briefcase.svg +16 -0
  22. package/brands/current/assets/glyphs/building-2.svg +21 -0
  23. package/brands/current/assets/glyphs/calendar.svg +18 -0
  24. package/brands/current/assets/glyphs/chart-column.svg +18 -0
  25. package/brands/current/assets/glyphs/chart-line.svg +16 -0
  26. package/brands/current/assets/glyphs/chart-pie.svg +16 -0
  27. package/brands/current/assets/glyphs/circle-check.svg +16 -0
  28. package/brands/current/assets/glyphs/clipboard-check.svg +17 -0
  29. package/brands/current/assets/glyphs/clock.svg +16 -0
  30. package/brands/current/assets/glyphs/cloud.svg +15 -0
  31. package/brands/current/assets/glyphs/code.svg +15 -0
  32. package/brands/current/assets/glyphs/coins.svg +18 -0
  33. package/brands/current/assets/glyphs/compass.svg +16 -0
  34. package/brands/current/assets/glyphs/cpu.svg +24 -0
  35. package/brands/current/assets/glyphs/database.svg +17 -0
  36. package/brands/current/assets/glyphs/eye.svg +16 -0
  37. package/brands/current/assets/glyphs/file-check.svg +17 -0
  38. package/brands/current/assets/glyphs/filter.svg +15 -0
  39. package/brands/current/assets/glyphs/fingerprint.svg +23 -0
  40. package/brands/current/assets/glyphs/flag.svg +16 -0
  41. package/brands/current/assets/glyphs/gauge.svg +16 -0
  42. package/brands/current/assets/glyphs/gavel.svg +19 -0
  43. package/brands/current/assets/glyphs/git-branch.svg +18 -0
  44. package/brands/current/assets/glyphs/globe.svg +17 -0
  45. package/brands/current/assets/glyphs/graduation-cap.svg +17 -0
  46. package/brands/current/assets/glyphs/handshake.svg +19 -0
  47. package/brands/current/assets/glyphs/hexagon.svg +15 -0
  48. package/brands/current/assets/glyphs/index.json +177 -0
  49. package/brands/current/assets/glyphs/infinity.svg +15 -0
  50. package/brands/current/assets/glyphs/key.svg +17 -0
  51. package/brands/current/assets/glyphs/landmark.svg +20 -0
  52. package/brands/current/assets/glyphs/layers.svg +17 -0
  53. package/brands/current/assets/glyphs/lightbulb.svg +17 -0
  54. package/brands/current/assets/glyphs/link.svg +16 -0
  55. package/brands/current/assets/glyphs/list-checks.svg +19 -0
  56. package/brands/current/assets/glyphs/lock.svg +16 -0
  57. package/brands/current/assets/glyphs/map.svg +17 -0
  58. package/brands/current/assets/glyphs/megaphone.svg +16 -0
  59. package/brands/current/assets/glyphs/message-square.svg +15 -0
  60. package/brands/current/assets/glyphs/milestone.svg +17 -0
  61. package/brands/current/assets/glyphs/network.svg +19 -0
  62. package/brands/current/assets/glyphs/orbit.svg +19 -0
  63. package/brands/current/assets/glyphs/package.svg +18 -0
  64. package/brands/current/assets/glyphs/plug.svg +18 -0
  65. package/brands/current/assets/glyphs/puzzle.svg +15 -0
  66. package/brands/current/assets/glyphs/radar.svg +22 -0
  67. package/brands/current/assets/glyphs/receipt.svg +17 -0
  68. package/brands/current/assets/glyphs/refresh-cw.svg +18 -0
  69. package/brands/current/assets/glyphs/rocket.svg +18 -0
  70. package/brands/current/assets/glyphs/route.svg +17 -0
  71. package/brands/current/assets/glyphs/scale.svg +19 -0
  72. package/brands/current/assets/glyphs/search.svg +16 -0
  73. package/brands/current/assets/glyphs/server.svg +18 -0
  74. package/brands/current/assets/glyphs/settings.svg +16 -0
  75. package/brands/current/assets/glyphs/share-2.svg +19 -0
  76. package/brands/current/assets/glyphs/shield-check.svg +16 -0
  77. package/brands/current/assets/glyphs/shuffle.svg +19 -0
  78. package/brands/current/assets/glyphs/sparkles.svg +19 -0
  79. package/brands/current/assets/glyphs/square-stack.svg +17 -0
  80. package/brands/current/assets/glyphs/target.svg +17 -0
  81. package/brands/current/assets/glyphs/trending-up.svg +16 -0
  82. package/brands/current/assets/glyphs/triangle-alert.svg +17 -0
  83. package/brands/current/assets/glyphs/trophy.svg +20 -0
  84. package/brands/current/assets/glyphs/users.svg +18 -0
  85. package/brands/current/assets/glyphs/wallet.svg +16 -0
  86. package/brands/current/assets/glyphs/wand-sparkles.svg +22 -0
  87. package/brands/current/assets/glyphs/workflow.svg +17 -0
  88. package/brands/current/assets/glyphs/zap.svg +15 -0
  89. package/brands/current/assets/mark-dark.png +0 -0
  90. package/brands/current/assets/mark-white.png +0 -0
  91. package/brands/current/assets/orb-cover.png +0 -0
  92. package/brands/current/assets/orb-glow.png +0 -0
  93. package/brands/current/assets/wordmark-dark.png +0 -0
  94. package/brands/current/assets/wordmark-white.png +0 -0
  95. package/brands/current/fonts/Geist-Bold.ttf +0 -0
  96. package/brands/current/fonts/Geist-BoldItalic.ttf +0 -0
  97. package/brands/current/fonts/Geist-ExtraBold.ttf +0 -0
  98. package/brands/current/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  99. package/brands/current/fonts/Geist-Italic.ttf +0 -0
  100. package/brands/current/fonts/Geist-Light.ttf +0 -0
  101. package/brands/current/fonts/Geist-LightItalic.ttf +0 -0
  102. package/brands/current/fonts/Geist-Medium.ttf +0 -0
  103. package/brands/current/fonts/Geist-MediumItalic.ttf +0 -0
  104. package/brands/current/fonts/Geist-Regular.ttf +0 -0
  105. package/brands/current/fonts/Geist-SemiBold.ttf +0 -0
  106. package/brands/current/fonts/Geist-SemiBoldItalic.ttf +0 -0
  107. package/brands/current/lib/glyphs.js +87 -0
  108. package/brands/current/lib/measure.js +115 -0
  109. package/brands/current/lib/pdf.js +603 -0
  110. package/brands/current/lib/ttf.py +167 -0
  111. package/brands/current/lib/wandxml.py +37 -0
  112. package/brands/current/tokens.json +206 -0
  113. package/brands/next/assets/closing-art.png +0 -0
  114. package/brands/next/assets/cover-art.png +0 -0
  115. package/brands/next/assets/divider-art.png +0 -0
  116. package/brands/next/assets/glyphs/README.md +29 -0
  117. package/brands/next/assets/glyphs/activity.svg +15 -0
  118. package/brands/next/assets/glyphs/atom.svg +17 -0
  119. package/brands/next/assets/glyphs/badge-check.svg +16 -0
  120. package/brands/next/assets/glyphs/bell.svg +16 -0
  121. package/brands/next/assets/glyphs/blocks.svg +16 -0
  122. package/brands/next/assets/glyphs/book-open.svg +16 -0
  123. package/brands/next/assets/glyphs/bot.svg +20 -0
  124. package/brands/next/assets/glyphs/boxes.svg +26 -0
  125. package/brands/next/assets/glyphs/brain.svg +23 -0
  126. package/brands/next/assets/glyphs/briefcase.svg +16 -0
  127. package/brands/next/assets/glyphs/building-2.svg +21 -0
  128. package/brands/next/assets/glyphs/calendar.svg +18 -0
  129. package/brands/next/assets/glyphs/chart-column.svg +18 -0
  130. package/brands/next/assets/glyphs/chart-line.svg +16 -0
  131. package/brands/next/assets/glyphs/chart-pie.svg +16 -0
  132. package/brands/next/assets/glyphs/circle-check.svg +16 -0
  133. package/brands/next/assets/glyphs/clipboard-check.svg +17 -0
  134. package/brands/next/assets/glyphs/clock.svg +16 -0
  135. package/brands/next/assets/glyphs/cloud.svg +15 -0
  136. package/brands/next/assets/glyphs/code.svg +15 -0
  137. package/brands/next/assets/glyphs/coins.svg +18 -0
  138. package/brands/next/assets/glyphs/compass.svg +16 -0
  139. package/brands/next/assets/glyphs/cpu.svg +24 -0
  140. package/brands/next/assets/glyphs/database.svg +17 -0
  141. package/brands/next/assets/glyphs/eye.svg +16 -0
  142. package/brands/next/assets/glyphs/file-check.svg +17 -0
  143. package/brands/next/assets/glyphs/filter.svg +15 -0
  144. package/brands/next/assets/glyphs/fingerprint.svg +23 -0
  145. package/brands/next/assets/glyphs/flag.svg +16 -0
  146. package/brands/next/assets/glyphs/gauge.svg +16 -0
  147. package/brands/next/assets/glyphs/gavel.svg +19 -0
  148. package/brands/next/assets/glyphs/git-branch.svg +18 -0
  149. package/brands/next/assets/glyphs/globe.svg +17 -0
  150. package/brands/next/assets/glyphs/graduation-cap.svg +17 -0
  151. package/brands/next/assets/glyphs/handshake.svg +19 -0
  152. package/brands/next/assets/glyphs/hexagon.svg +15 -0
  153. package/brands/next/assets/glyphs/index.json +177 -0
  154. package/brands/next/assets/glyphs/infinity.svg +15 -0
  155. package/brands/next/assets/glyphs/key.svg +17 -0
  156. package/brands/next/assets/glyphs/landmark.svg +20 -0
  157. package/brands/next/assets/glyphs/layers.svg +17 -0
  158. package/brands/next/assets/glyphs/lightbulb.svg +17 -0
  159. package/brands/next/assets/glyphs/link.svg +16 -0
  160. package/brands/next/assets/glyphs/list-checks.svg +19 -0
  161. package/brands/next/assets/glyphs/lock.svg +16 -0
  162. package/brands/next/assets/glyphs/map.svg +17 -0
  163. package/brands/next/assets/glyphs/megaphone.svg +16 -0
  164. package/brands/next/assets/glyphs/message-square.svg +15 -0
  165. package/brands/next/assets/glyphs/milestone.svg +17 -0
  166. package/brands/next/assets/glyphs/network.svg +19 -0
  167. package/brands/next/assets/glyphs/orbit.svg +19 -0
  168. package/brands/next/assets/glyphs/package.svg +18 -0
  169. package/brands/next/assets/glyphs/plug.svg +18 -0
  170. package/brands/next/assets/glyphs/puzzle.svg +15 -0
  171. package/brands/next/assets/glyphs/radar.svg +22 -0
  172. package/brands/next/assets/glyphs/receipt.svg +17 -0
  173. package/brands/next/assets/glyphs/refresh-cw.svg +18 -0
  174. package/brands/next/assets/glyphs/rocket.svg +18 -0
  175. package/brands/next/assets/glyphs/route.svg +17 -0
  176. package/brands/next/assets/glyphs/scale.svg +19 -0
  177. package/brands/next/assets/glyphs/search.svg +16 -0
  178. package/brands/next/assets/glyphs/server.svg +18 -0
  179. package/brands/next/assets/glyphs/settings.svg +16 -0
  180. package/brands/next/assets/glyphs/share-2.svg +19 -0
  181. package/brands/next/assets/glyphs/shield-check.svg +16 -0
  182. package/brands/next/assets/glyphs/shuffle.svg +19 -0
  183. package/brands/next/assets/glyphs/sparkles.svg +19 -0
  184. package/brands/next/assets/glyphs/square-stack.svg +17 -0
  185. package/brands/next/assets/glyphs/target.svg +17 -0
  186. package/brands/next/assets/glyphs/trending-up.svg +16 -0
  187. package/brands/next/assets/glyphs/triangle-alert.svg +17 -0
  188. package/brands/next/assets/glyphs/trophy.svg +20 -0
  189. package/brands/next/assets/glyphs/users.svg +18 -0
  190. package/brands/next/assets/glyphs/wallet.svg +16 -0
  191. package/brands/next/assets/glyphs/wand-sparkles.svg +22 -0
  192. package/brands/next/assets/glyphs/workflow.svg +17 -0
  193. package/brands/next/assets/glyphs/zap.svg +15 -0
  194. package/brands/next/assets/layers-dark.png +0 -0
  195. package/brands/next/assets/layers-light.png +0 -0
  196. package/brands/next/assets/ornaments/burst-dark.png +0 -0
  197. package/brands/next/assets/ornaments/burst-light.png +0 -0
  198. package/brands/next/assets/ornaments/chain-dark.png +0 -0
  199. package/brands/next/assets/ornaments/chain-light.png +0 -0
  200. package/brands/next/assets/ornaments/coil-dark.png +0 -0
  201. package/brands/next/assets/ornaments/coil-light.png +0 -0
  202. package/brands/next/assets/ornaments/cone-dark.png +0 -0
  203. package/brands/next/assets/ornaments/cone-light.png +0 -0
  204. package/brands/next/assets/ornaments/diamond-dark.png +0 -0
  205. package/brands/next/assets/ornaments/diamond-light.png +0 -0
  206. package/brands/next/assets/ornaments/fan-dark.png +0 -0
  207. package/brands/next/assets/ornaments/fan-light.png +0 -0
  208. package/brands/next/assets/ornaments/globe-dark.png +0 -0
  209. package/brands/next/assets/ornaments/globe-light.png +0 -0
  210. package/brands/next/assets/ornaments/orbit-dark.png +0 -0
  211. package/brands/next/assets/ornaments/orbit-light.png +0 -0
  212. package/brands/next/assets/ornaments/rings-dark.png +0 -0
  213. package/brands/next/assets/ornaments/rings-light.png +0 -0
  214. package/brands/next/assets/ornaments/sphere-dark.png +0 -0
  215. package/brands/next/assets/ornaments/sphere-light.png +0 -0
  216. package/brands/next/assets/ornaments/sun-dark.png +0 -0
  217. package/brands/next/assets/ornaments/sun-light.png +0 -0
  218. package/brands/next/assets/photos/atrium.jpg +0 -0
  219. package/brands/next/assets/photos/cantilever.jpg +0 -0
  220. package/brands/next/assets/photos/corridor.jpg +0 -0
  221. package/brands/next/assets/photos/dome.jpg +0 -0
  222. package/brands/next/assets/photos/facade.jpg +0 -0
  223. package/brands/next/assets/photos/glass.jpg +0 -0
  224. package/brands/next/assets/photos/stairs.jpg +0 -0
  225. package/brands/next/assets/photos/tower.jpg +0 -0
  226. package/brands/next/assets/photos/walk.jpg +0 -0
  227. package/brands/next/assets/spark-gold.png +0 -0
  228. package/brands/next/assets/statement-art.png +0 -0
  229. package/brands/next/assets/wordmark-dark.png +0 -0
  230. package/brands/next/assets/wordmark-light.png +0 -0
  231. package/brands/next/assets/wordmark.svg +7 -0
  232. package/brands/next/fonts/FKRomanStandard-Light.ttf +0 -0
  233. package/brands/next/fonts/OFL-OverusedGrotesk.txt +93 -0
  234. package/brands/next/fonts/OverusedGrotesk-Italic.ttf +0 -0
  235. package/brands/next/fonts/OverusedGrotesk-Medium.ttf +0 -0
  236. package/brands/next/fonts/OverusedGrotesk-MediumItalic.ttf +0 -0
  237. package/brands/next/fonts/OverusedGrotesk-Roman.ttf +0 -0
  238. package/brands/next/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
  239. package/brands/next/fonts/OverusedGrotesk-SemiBoldItalic.ttf +0 -0
  240. package/brands/next/fonts/faces.json +27 -0
  241. package/brands/next/lib/glyphs.js +89 -0
  242. package/brands/next/lib/measure.js +121 -0
  243. package/brands/next/lib/pdf.js +597 -0
  244. package/brands/next/lib/ttf.py +169 -0
  245. package/brands/next/lib/wandxml.py +37 -0
  246. package/brands/next/tokens.json +174 -0
  247. package/examples/gallery/job.mjs +83 -0
  248. package/examples/overview/job.mjs +99 -0
  249. package/lib/brand.js +135 -0
  250. package/lib/components.js +313 -0
  251. package/lib/content.js +66 -0
  252. package/lib/engine.js +280 -0
  253. package/lib/html_export.js +71 -0
  254. package/lib/pptx.js +446 -0
  255. package/lib/surfaces.js +62 -0
  256. package/package.json +17 -4
  257. package/templates/card.mjs +30 -0
  258. package/templates/page.mjs +52 -0
  259. package/templates/slide.mjs +46 -0
  260. package/tools/embed_fonts.py +154 -0
  261. package/wand.js +179 -0
  262. package/README.md +0 -3
package/lib/engine.js ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * lib/engine.js — one design surface, read back by every export.
3
+ *
4
+ * A job (`job.mjs`) names a brand, a surface and its content, and composes
5
+ * pages from the components. The engine assembles that into one HTML
6
+ * document, opens it in Chromium, runs every gate against the rendered DOM,
7
+ * and writes what was reviewed on screen: the PDF, a PNG per page, a contact
8
+ * sheet. The PowerPoint and single-file HTML exports read the same render.
9
+ *
10
+ * Nothing is trusted that was not measured here: fit, overlap, colours,
11
+ * faces, type size, contrast and the words are all read from Chromium's own
12
+ * layout of the page that becomes the PDF.
13
+ */
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { pathToFileURL, fileURLToPath } from "node:url";
18
+ import { createRequire } from "node:module";
19
+ import { execFileSync, spawnSync } from "node:child_process";
20
+ import { loadBrand, DEFAULT_BRAND } from "./brand.js";
21
+ import { surface as surfaceOf, surfaceCss } from "./surfaces.js";
22
+ import { bind, COMPONENTS_CSS } from "./components.js";
23
+ import { loadContent, coverage } from "./content.js";
24
+
25
+ const require = createRequire(import.meta.url);
26
+ export const KIT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
27
+
28
+ /* ------------------------------------------------------------- the browser */
29
+
30
+ /** A Chromium to render with, or null. The env, Playwright's own cache, then the system. */
31
+ export function findChrome() {
32
+ const ok = (p) => p && fs.existsSync(p) && p;
33
+ if (process.env.WAND_CHROME) return ok(process.env.WAND_CHROME) || null;
34
+ try { const { chromium } = require("playwright-core"); const p = chromium.executablePath(); if (ok(p)) return p; } catch {}
35
+ const caches = [process.env.PLAYWRIGHT_BROWSERS_PATH, path.join(os.homedir(), "Library", "Caches", "ms-playwright"),
36
+ path.join(os.homedir(), ".cache", "ms-playwright"), "/ms-playwright", "/root/.cache/ms-playwright", "/opt/ms-playwright"].filter(Boolean);
37
+ const inside = ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium",
38
+ "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-linux/chrome", "chrome-linux64/chrome",
39
+ "chrome-headless-shell-linux64/chrome-headless-shell", "chrome-headless-shell-mac-arm64/chrome-headless-shell", "chrome-win/chrome.exe"];
40
+ for (const c of caches) {
41
+ if (!fs.existsSync(c)) continue;
42
+ const dirs = fs.readdirSync(c).filter((d) => /^chromium(_headless_shell)?-\d+$/.test(d)).sort((a, b) => (+b.match(/\d+/)[0]) - (+a.match(/\d+/)[0]));
43
+ for (const d of dirs) for (const i of inside) { const p = ok(path.join(c, d, i)); if (p) return p; }
44
+ }
45
+ for (const p of ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium",
46
+ "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/snap/bin/chromium",
47
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"]) if (ok(p)) return p;
48
+ for (const c of ["chromium", "chromium-browser", "google-chrome", "chrome"]) {
49
+ const r = spawnSync("which", [c], { encoding: "utf8" });
50
+ if (r.status === 0 && ok(r.stdout.trim())) return r.stdout.trim();
51
+ }
52
+ return null;
53
+ }
54
+
55
+ export const has = (c) => spawnSync("which", [c]).status === 0;
56
+
57
+ let shared = null;
58
+ /** One browser for the process; `watch` keeps it warm. */
59
+ export async function browser() {
60
+ if (shared) return shared;
61
+ const exe = findChrome();
62
+ if (!exe) throw new Error("no Chromium found — set WAND_CHROME to a browser binary, or install one (npx playwright-core install chromium)");
63
+ const { chromium } = require("playwright-core");
64
+ shared = await chromium.launch({ executablePath: exe, args: ["--font-render-hinting=none"] });
65
+ return shared;
66
+ }
67
+ export async function closeBrowser() { if (shared) { await shared.close(); shared = null; } }
68
+
69
+ /* ---------------------------------------------------------------- the job */
70
+
71
+ export async function loadJob(jobPath) {
72
+ const file = path.resolve(jobPath);
73
+ if (!fs.existsSync(file)) throw new Error(`no such job: ${file}`);
74
+ const mod = await import(pathToFileURL(file).href + `?t=${Date.now()}`);
75
+ const job = mod.default;
76
+ if (!job || typeof job.pages !== "function") throw new Error(`${path.basename(file)} must export default { ..., pages(c) }`);
77
+ return { job, file, dir: path.dirname(file) };
78
+ }
79
+
80
+ /** The whole document: brand, surface and component CSS, the job's CSS, then its pages. */
81
+ export function assemble({ brand, surface, job, pages }) {
82
+ const title = job.name || "Wand";
83
+ const body = Array.isArray(pages) ? pages.join("\n") : String(pages);
84
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title.replace(/</g, "&lt;")}</title>
85
+ <meta name="viewport" content="width=${surface.w}"><meta name="wand-brand" content="${brand.name}"><meta name="wand-surface" content="${surface.name}">
86
+ <style>${brand.css()}</style>
87
+ <style>${surfaceCss(surface)}</style>
88
+ <style>${COMPONENTS_CSS}</style>
89
+ <style>${job.css || ""}</style>
90
+ </head><body class="surface-${surface.name} brand-${brand.name}">
91
+ ${body}
92
+ </body></html>`;
93
+ }
94
+
95
+ const stem = (job, file) => (job.file || job.name || path.basename(path.dirname(file))).replace(/[\/\\:]+/g, "-").replace(/\s+/g, "_");
96
+
97
+ /* ------------------------------------------------------------------ gates */
98
+
99
+ /** Runs inside the page. Everything is read from the rendered DOM. */
100
+ function GATES({ min, chars, tokens, families, base }) {
101
+ const T = new Set(tokens);
102
+ const pages = [...document.querySelectorAll(".page")];
103
+ const toHex = (c) => {
104
+ const m = String(c).match(/rgba?\(([^)]+)\)/); if (!m) return null;
105
+ const p = m[1].split(/[ ,\/]+/).filter(Boolean).map(Number); if (p.length > 3 && p[3] === 0) return null;
106
+ return p.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, "0")).join("").toUpperCase();
107
+ };
108
+ const sel = (el) => el.tagName.toLowerCase() + (typeof el.className === "string" && el.className.trim() ? "." + el.className.trim().split(/\s+/).slice(0, 3).join(".") : "");
109
+ 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
+ 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
+ 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: [] };
113
+ const seen = new Set();
114
+ pages.forEach((pg, pi) => {
115
+ const n = pi + 1;
116
+ const content = pg.querySelector(".content");
117
+ const box = (content || pg).getBoundingClientRect();
118
+ const foot = pg.querySelector("footer")?.getBoundingClientRect();
119
+ const floor = foot ? Math.min(box.bottom, foot.top - 6) : box.bottom;
120
+ let over = 0, why = "", low = -Infinity;
121
+ for (const el of (content || pg).querySelectorAll("*")) {
122
+ if (el.closest(".deco")) continue;
123
+ const r = el.getBoundingClientRect(); if (!r.width || !r.height) continue;
124
+ const cs = getComputedStyle(el);
125
+ const bump = (v, k) => { if (v > over) { over = v; why = `${k} ${sel(el)}`; } };
126
+ bump(r.bottom - floor, "below");
127
+ if (cs.display !== "inline") bump(box.top - r.top, "above");
128
+ bump(r.right - box.right - 1, "right"); bump(box.left - r.left - 1, "left");
129
+ low = Math.max(low, r.bottom);
130
+ }
131
+ res.fit.push({ page: n, over: Math.round(over), why, slack: Math.round(floor - low) });
132
+
133
+ const all = [...pg.querySelectorAll("*")];
134
+ const leaves = all.filter((e) => !e.closest(".deco, svg, script, style") && [...e.childNodes].some((c) => c.nodeType === 3 && c.data.trim()));
135
+ const boxes = leaves.map((e) => ({ e, r: e.getBoundingClientRect() })).filter((x) => x.r.width && x.r.height);
136
+ for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
137
+ const A = boxes[i], B = boxes[j];
138
+ if (A.e.contains(B.e) || B.e.contains(A.e)) continue;
139
+ const w = Math.min(A.r.right, B.r.right) - Math.max(A.r.left, B.r.left), h = Math.min(A.r.bottom, B.r.bottom) - Math.max(A.r.top, B.r.top);
140
+ if (w > 2 && h > 2) res.overlap.push({ page: n, a: sel(A.e), b: sel(B.e), px: `${Math.round(w)}×${Math.round(h)}` });
141
+ }
142
+ for (const el of all) {
143
+ if (el.closest("script, style")) continue;
144
+ const cs = getComputedStyle(el);
145
+ const inSvg = !!el.closest("svg");
146
+ const props = inSvg ? ["fill", "stroke"] : ["color", "background-color", "border-top-color", "border-bottom-color", "border-left-color", "border-right-color"];
147
+ for (const prop of props) {
148
+ const v = cs.getPropertyValue(prop); if (!v || v === "none" || v === "currentcolor") continue;
149
+ if (prop.startsWith("border-") && !(parseFloat(cs.getPropertyValue(prop.replace("-color", "-width"))) > 0 && cs.getPropertyValue(prop.replace("-color", "-style")) !== "none")) continue;
150
+ const h = toHex(v); if (!h || T.has(h)) continue;
151
+ const k = `${h}|${sel(el)}|${prop}`; if (seen.has(k)) continue; seen.add(k);
152
+ res.brand.push({ page: n, el: sel(el), prop, hex: h });
153
+ }
154
+ if (!leaves.includes(el)) continue;
155
+ const fam = cs.fontFamily.split(",")[0].replace(/["']/g, "").trim();
156
+ if (!families.includes(fam)) { const k = `f|${fam}|${sel(el)}`; if (!seen.has(k)) { seen.add(k); res.fonts.push({ page: n, el: sel(el), family: fam }); } }
157
+ const size = parseFloat(cs.fontSize);
158
+ if (size < min - 0.05) res.type.push({ page: n, el: sel(el), size: +size.toFixed(1) });
159
+ const fg = toHex(cs.color), bg = bgOf(el);
160
+ if (fg && bg && !el.closest(".eyebrow, footer")) { const ratio = contrast(fg, bg), need = size >= base * 1.5 ? 3 : 4.5; if (ratio < need) res.contrast.push({ page: n, el: sel(el), ratio: +ratio.toFixed(1), need }); }
161
+ const r = el.getBoundingClientRect(), lh = parseFloat(cs.lineHeight) || size * 1.4;
162
+ const inner = r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
163
+ 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)) });
164
+ }
165
+ const text = pg.innerText || "";
166
+ if (text.includes("\u2014")) res.dash.push(n);
167
+ res.texts.push(text);
168
+ res.notes.push(pg.dataset.notes || "");
169
+ });
170
+ return res;
171
+ }
172
+
173
+ /* ------------------------------------------------------------------ build */
174
+
175
+ /**
176
+ * Build a job: gates, and unless `outputs` is false, the PDF, the PNGs and the
177
+ * sheet in `<job dir>/out/`. Returns { ok, rows, outputs, html, texts }.
178
+ */
179
+ export async function build(jobPath, { outputs: wantOutputs = true, outDir = null, quiet = false } = {}) {
180
+ const t0 = Date.now();
181
+ const { job, file, dir } = await loadJob(jobPath);
182
+ const brand = loadBrand(job.brand || DEFAULT_BRAND, job.theme || null);
183
+ const surface = surfaceOf(job.surface || "letter");
184
+ const content = loadContent(dir, job);
185
+ const c = bind({ brand, surface, job, content });
186
+ const pages = job.pages(c);
187
+ const html = assemble({ brand, surface, job, pages });
188
+ const out = outDir || path.join(dir, "out");
189
+ const work = path.join(out, "work");
190
+ fs.mkdirSync(work, { recursive: true });
191
+ const name = stem(job, file);
192
+ const htmlPath = path.join(work, `${name}.html`);
193
+ fs.writeFileSync(htmlPath, html);
194
+
195
+ const b = await browser();
196
+ const page = await b.newPage({ viewport: { width: surface.w, height: surface.h } });
197
+ const errors = [];
198
+ page.on("pageerror", (e) => errors.push(String(e)));
199
+ page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
200
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
201
+ await page.evaluate(() => document.fonts.ready);
202
+ await page.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
203
+ await page.waitForTimeout(120);
204
+
205
+ const g = await page.evaluate(GATES, { min: surface.min, chars: surface.chars, tokens: [...brand.tokenSet], families: brand.families, base: surface.base });
206
+ const pageCount = g.fit.length;
207
+ const text = g.texts.join("\n");
208
+ const cov = content.has ? coverage(content, text, job.splitters) : null;
209
+ const deny = (job.denylist || []).map((d) => (d instanceof RegExp ? d : new RegExp(d, "i"))).filter((re) => re.test(text)).map(String);
210
+
211
+ const rows = [];
212
+ const row = (ok, gate, msg, warn = false) => rows.push({ ok, gate, msg, warn });
213
+ const badFit = g.fit.filter((f) => f.over > 0);
214
+ row(!badFit.length, "fit", badFit.length ? badFit.map((f) => `p${f.page} +${f.over}px (${f.why})`).join(", ")
215
+ : `${pageCount} page${pageCount === 1 ? "" : "s"} (slack px: ${g.fit.map((f) => f.slack).join(" ")})`);
216
+ row(!cov || !cov.miss.length, "coverage", !cov ? "no content file — authored copy, nothing to cover"
217
+ : cov.miss.length ? `${cov.found}/${cov.total} — missing lines ${cov.miss.join(", ")}` : `${cov.found}/${cov.total} lines on the page${content.cut.size ? `, ${content.cut.size} cut` : ""}${content.edited ? `, ${content.edited} edits declared` : ""}`);
218
+ row(!g.overlap.length, "overlap", g.overlap.length ? g.overlap.slice(0, 4).map((o) => `p${o.page} ${o.a} × ${o.b} (${o.px})`).join("; ") + (g.overlap.length > 4 ? ` +${g.overlap.length - 4}` : "") : "no text on text");
219
+ row(!g.brand.length, "brand", g.brand.length ? `${g.brand.length} off-token: ` + g.brand.slice(0, 4).map((o) => `p${o.page} ${o.el} ${o.prop} #${o.hex}`).join("; ") + (g.brand.length > 4 ? " …" : "")
220
+ : `every colour a token${brand.declared.size ? ` (${brand.declared.size} declared by the job)` : ""}`);
221
+ row(!g.fonts.length, "fonts", g.fonts.length ? g.fonts.slice(0, 4).map((o) => `p${o.page} ${o.el} in ${o.family}`).join("; ") : `${brand.families.join(", ")} only`);
222
+ row(!g.type.length, "type", g.type.length ? `under ${surface.min}px: ` + g.type.slice(0, 4).map((o) => `p${o.page} ${o.el} ${o.size}px`).join("; ") : `nothing under ${surface.min}px`);
223
+ row(!g.dash.length, "dashes", g.dash.length ? `em dash on p${g.dash.join(", p")}` : "no em dashes");
224
+ row(!deny.length, "names", job.denylist?.length ? (deny.length ? `found ${deny.join(", ")}` : `none of ${job.denylist.length} denied names`) : "no denylist", !job.denylist?.length);
225
+ 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);
226
+ 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);
227
+ row(!errors.length, "console", errors.length ? errors.slice(0, 2).join(" | ") : "no errors");
228
+
229
+ const outputs = { html: htmlPath };
230
+ if (wantOutputs) {
231
+ const pdf = path.join(out, `${name}.pdf`);
232
+ await page.pdf({ path: pdf, width: `${surface.w}px`, height: `${surface.h}px`, printBackground: true, preferCSSPageSize: true });
233
+ outputs.pdf = pdf;
234
+ const shots = path.join(out, "pages");
235
+ fs.rmSync(shots, { recursive: true, force: true }); fs.mkdirSync(shots, { recursive: true });
236
+ const els = await page.$$(".page");
237
+ outputs.pngs = [];
238
+ for (let i = 0; i < els.length; i++) { const p = path.join(shots, `p${String(i + 1).padStart(2, "0")}.png`); await els[i].screenshot({ path: p }); outputs.pngs.push(p); }
239
+ outputs.sheet = await sheet(b, outputs.pngs, path.join(out, "sheet.png"), surface);
240
+ if (has("pdftotext")) {
241
+ const pdfText = execFileSync("pdftotext", ["-raw", pdf, "-"], { encoding: "utf8" });
242
+ const pc = content.has ? coverage(content, pdfText, job.splitters) : null;
243
+ const dash = pdfText.includes("\u2014");
244
+ row(!(pc && pc.miss.length) && !dash, "pdf text", dash ? "em dash in the PDF text" : pc ? (pc.miss.length ? `missing lines ${pc.miss.join(", ")}` : `${pc.found}/${pc.total} lines read back from the PDF`) : "readable");
245
+ }
246
+ row(true, "renders", `${path.relative(process.cwd(), shots)}/p01…${String(els.length).padStart(2, "0")}.png, ${path.relative(process.cwd(), outputs.sheet)}`);
247
+ }
248
+ await page.close();
249
+
250
+ const ok = rows.every((r) => r.ok || r.warn);
251
+ const report = { ok, rows, outputs, pages: pageCount, brand: brand.name, surface: surface.name, ms: Date.now() - t0, texts: g.texts, notes: g.notes, name };
252
+ fs.writeFileSync(path.join(work, "report.json"), JSON.stringify({ ...report, texts: undefined }, null, 1));
253
+ if (!quiet) printReport(report);
254
+ return report;
255
+ }
256
+
257
+ export function printReport(r) {
258
+ console.log("");
259
+ for (const row of r.rows) console.log(` ${row.ok ? " ok " : row.warn ? " -- " : "FAIL"} ${row.gate.padEnd(10)} ${row.msg}`);
260
+ 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)`);
261
+ if (r.outputs.pptx) console.log(` ${path.relative(process.cwd(), r.outputs.pptx)} (${(fs.statSync(r.outputs.pptx).size / 1e6).toFixed(1)} MB)`);
262
+ 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)`);
263
+ console.log(r.ok ? "" : "\n FAILED — fix the rows marked FAIL\n");
264
+ }
265
+
266
+ /** Every page on one image, for review. */
267
+ async function sheet(b, pngs, outPath, surface) {
268
+ const cols = surface.kind === "slide" ? 3 : Math.min(4, pngs.length);
269
+ const w = surface.kind === "slide" ? 560 : 300, h = Math.round((w * surface.h) / surface.w);
270
+ const html = `<!doctype html><body style="margin:0;background:#1a1a1f;padding:16px;display:grid;grid-template-columns:repeat(${cols},${w}px);gap:14px;width:${cols * (w + 14) + 18}px">` +
271
+ pngs.map((p, i) => `<figure style="margin:0;color:#aaa;font:11px system-ui"><img src="${pathToFileURL(p).href}" style="display:block;width:${w}px;height:${h}px;box-shadow:0 2px 12px #0008"><figcaption style="margin-top:4px">${String(i + 1).padStart(2, "0")}</figcaption></figure>`).join("") + "</body>";
272
+ const tmp = path.join(path.dirname(outPath), "work", "sheet.html");
273
+ fs.writeFileSync(tmp, html);
274
+ const pg = await b.newPage({ viewport: { width: cols * (w + 14) + 34, height: 400 } });
275
+ await pg.goto(pathToFileURL(tmp).href, { waitUntil: "load" });
276
+ await pg.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
277
+ await pg.screenshot({ path: outPath, fullPage: true });
278
+ await pg.close();
279
+ return outPath;
280
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * lib/html_export.js — the job as one HTML file that opens anywhere, offline.
3
+ *
4
+ * The working HTML refers to fonts and art by file path. This inlines every
5
+ * one as a data URI, so the file carries the brand with it, and adds a small
6
+ * runtime: on a slide or card surface, one page at a time scaled to the
7
+ * window, arrow keys and clicks to move, G for the grid, print gives one
8
+ * sheet per page; on a page surface, the pages stacked and scaled to fit.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
15
+ ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2" };
16
+
17
+ export function singleFile(htmlPath, outPath, { kind = "page", w, h }) {
18
+ let html = fs.readFileSync(htmlPath, "utf8");
19
+ const cache = new Map(), missing = [];
20
+ html = html.replace(/file:\/\/[^"')\s]+/g, (u) => {
21
+ if (cache.has(u)) return cache.get(u);
22
+ let p; try { p = fileURLToPath(u); } catch { return u; }
23
+ if (!fs.existsSync(p)) { missing.push(p); return u; }
24
+ const ext = path.extname(p).slice(1).toLowerCase();
25
+ const d = `data:${MIME[ext] || "application/octet-stream"};base64,${fs.readFileSync(p).toString("base64")}`;
26
+ cache.set(u, d); return d;
27
+ });
28
+ html = html.replace("</body>", `${RUNTIME_CSS}<script>${RUNTIME_JS}</script></body>`)
29
+ .replace("<body", `<body data-kind="${kind}" data-w="${w}" data-h="${h}"`);
30
+ fs.writeFileSync(outPath, html);
31
+ return { out: outPath, missing, bytes: fs.statSync(outPath).size };
32
+ }
33
+
34
+ const RUNTIME_CSS = `<style id="wand-runtime">
35
+ html,body{margin:0;background:#101014}
36
+ body.stage{overflow:hidden;height:100vh}
37
+ body.stage .page{position:absolute;left:50%;top:50%;transform-origin:center;display:none}
38
+ body.stage .page.on{display:block}
39
+ body.stack{padding:24px 0}
40
+ body.stack .page{margin:0 auto 24px;transform-origin:top center}
41
+ .wand-dock{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);display:flex;gap:10px;align-items:center;padding:8px 14px;border-radius:999px;background:#0008;color:#ddd;font:13px/1 system-ui;opacity:0;transition:opacity .2s;pointer-events:none}
42
+ body.moving .wand-dock{opacity:1;pointer-events:auto}
43
+ .wand-dock button{background:none;border:0;color:#ddd;font:inherit;cursor:pointer;padding:4px 6px}
44
+ body.grid{overflow:auto}
45
+ body.grid .page{position:static;display:block;transform:none;zoom:.22;margin:12px;float:left;cursor:pointer;box-shadow:0 2px 14px #000a}
46
+ @media print{html,body{background:#fff}body.stage .page,body.stack .page{position:static;display:block;transform:none;margin:0;break-after:page}.wand-dock{display:none}}
47
+ </style>`;
48
+
49
+ const RUNTIME_JS = `(() => {
50
+ const B = document.body, W = +B.dataset.w, H = +B.dataset.h, kind = B.dataset.kind;
51
+ const pages = [...document.querySelectorAll(".page")];
52
+ if (kind === "page") {
53
+ B.classList.add("stack");
54
+ const fit = () => { const k = Math.min(1, (innerWidth - 48) / W); pages.forEach((p) => { p.style.transform = "scale(" + k + ")"; p.style.marginBottom = (24 - H * (1 - k)) + "px"; }); };
55
+ addEventListener("resize", fit); fit(); return;
56
+ }
57
+ B.classList.add("stage");
58
+ let i = Math.max(0, Math.min(pages.length - 1, (parseInt(location.hash.slice(1)) || 1) - 1));
59
+ const dock = document.createElement("div"); dock.className = "wand-dock";
60
+ dock.innerHTML = '<button data-go="-1">‹</button><span class="n"></span><button data-go="1">›</button><button data-grid>grid</button>';
61
+ B.appendChild(dock);
62
+ const fit = () => { const k = Math.min(innerWidth / W, innerHeight / H); pages.forEach((p) => { p.style.transform = "translate(-50%,-50%) scale(" + k + ")"; }); };
63
+ const show = () => { pages.forEach((p, k) => p.classList.toggle("on", k === i)); dock.querySelector(".n").textContent = String(i + 1).padStart(2, "0") + " / " + pages.length; history.replaceState(null, "", "#" + (i + 1)); };
64
+ const go = (d) => { i = Math.max(0, Math.min(pages.length - 1, i + d)); show(); };
65
+ dock.addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; if (b.dataset.grid !== undefined) B.classList.toggle("grid"); else go(+b.dataset.go); });
66
+ addEventListener("keydown", (e) => { if (e.key === "ArrowRight" || e.key === " " || e.key === "PageDown") go(1); else if (e.key === "ArrowLeft" || e.key === "PageUp") go(-1);
67
+ else if (e.key === "Home") { i = 0; show(); } else if (e.key === "End") { i = pages.length - 1; show(); } else if (e.key.toLowerCase() === "g") B.classList.toggle("grid"); else if (e.key.toLowerCase() === "f") document.documentElement.requestFullscreen?.(); });
68
+ pages.forEach((p, k) => p.addEventListener("click", (e) => { if (B.classList.contains("grid")) { i = k; B.classList.remove("grid"); show(); } else go(e.shiftKey ? -1 : 1); }));
69
+ let t; addEventListener("mousemove", () => { B.classList.add("moving"); clearTimeout(t); t = setTimeout(() => B.classList.remove("moving"), 1800); });
70
+ addEventListener("resize", fit); fit(); show();
71
+ })();`;