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/brand.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * lib/brand.js — the two brands, resolved and turned into CSS.
3
+ *
4
+ * A brand is a directory with `tokens.json` (swatch, roles, faces, shape, art),
5
+ * `fonts/`, `assets/` and `lib/` (the Python helpers the exporters borrow).
6
+ * The kit runs in two shapes and finds the directory in both:
7
+ *
8
+ * repo power-prompts/studio/kit/ -> ../../../brand and brand-next
9
+ * packaged tools/package.mjs copies each into brands/<name>/ beside the kit
10
+ *
11
+ * `WAND_BRANDS_DIR` overrides both: it holds <name>/ per brand.
12
+ *
13
+ * Nothing downstream sees a hex. `css()` writes the roles as custom properties
14
+ * on `.page` (light) and `.page.dark`, the faces as @font-face rules, and the
15
+ * art as paths; a job may declare a `theme` — a client's own colours — which
16
+ * is merged into the roles and reported, by the brand gate, as declared.
17
+ * `theme.extra` lists colours the page carries outside the roles: as an array
18
+ * they are only declared; as an object (`{ navy: "240F4F" }`) each is also
19
+ * written as a custom property (`--navy`) so the job's CSS can use it.
20
+ */
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const KIT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
26
+ const REPO_DIRS = { current: "brand", next: "brand-next" };
27
+ export const BRANDS = Object.keys(REPO_DIRS);
28
+
29
+ // The brand a job gets when the user names none. While the two brands overlap
30
+ // this is the only switch: set it to "next" and publish, and every chat builds
31
+ // on the website's brand unless the user asks for the current one. The skill
32
+ // never states the default; `doctor` prints it from here.
33
+ export const DEFAULT_BRAND = "current";
34
+
35
+ // What a user says when they want each brand, so the rule reads the same
36
+ // whichever way the default points.
37
+ export const ASKED_AS = {
38
+ current: ["current brand", "old brand", "existing guidelines", "Geist"],
39
+ next: ["new brand", "new branding", "new brand guidelines", "the website's look"],
40
+ };
41
+
42
+ /** The default and the words that switch away from it, as one line. */
43
+ 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)}`;
47
+ }
48
+
49
+ /** Absolute path of a brand directory, or an error naming every place looked. */
50
+ export function brandDir(name) {
51
+ if (!REPO_DIRS[name]) throw new Error(`unknown brand "${name}" — one of ${BRANDS.join(", ")}`);
52
+ const tried = [];
53
+ for (const dir of candidates(name)) {
54
+ tried.push(dir);
55
+ const t = path.join(dir, "tokens.json");
56
+ if (fs.existsSync(t) && JSON.parse(fs.readFileSync(t, "utf8")).roles) return dir;
57
+ }
58
+ throw new Error(`brand "${name}" not found. Looked in:\n ${tried.join("\n ")}`);
59
+ }
60
+
61
+ function candidates(name) {
62
+ const out = [];
63
+ if (process.env.WAND_BRANDS_DIR) out.push(path.resolve(process.env.WAND_BRANDS_DIR, name));
64
+ out.push(path.join(KIT, "brands", name));
65
+ out.push(path.join(KIT, "..", "..", "..", REPO_DIRS[name]));
66
+ return out;
67
+ }
68
+
69
+ const hex6 = (h) => String(h).replace(/^#/, "").toUpperCase();
70
+ export const url = (p) => "file://" + p;
71
+
72
+ /**
73
+ * Load a brand, with an optional job theme merged over its roles.
74
+ * theme: { light: { accent: "5F30C5" }, dark: {...}, extra: ["1C0943"] | { navy: "240F4F" } }
75
+ */
76
+ export function loadBrand(name, theme = null) {
77
+ const dir = brandDir(name);
78
+ const tokens = JSON.parse(fs.readFileSync(path.join(dir, "tokens.json"), "utf8"));
79
+ const roles = { light: { ...tokens.roles.light }, dark: { ...tokens.roles.dark } };
80
+ const declared = new Set(), extras = {};
81
+ if (theme) {
82
+ for (const g of ["light", "dark"]) for (const [k, v] of Object.entries(theme[g] || {})) {
83
+ if (!(k in roles[g])) throw new Error(`theme.${g}.${k}: not a role (roles: ${Object.keys(roles[g]).join(", ")})`);
84
+ roles[g][k] = hex6(v); declared.add(hex6(v));
85
+ }
86
+ const extra = theme.extra || [];
87
+ for (const [k, v] of Object.entries(extra)) { declared.add(hex6(v)); if (!Array.isArray(extra)) extras[k] = hex6(v); }
88
+ }
89
+ const fonts = path.join(dir, "fonts"), assets = path.join(dir, "assets"), lib = path.join(dir, "lib");
90
+ for (const f of tokens.faces.files) if (!fs.existsSync(path.join(fonts, f.file))) throw new Error(`${name}: font ${f.file} missing from ${fonts}`);
91
+
92
+ // every colour a page may carry: both grounds' roles, the swatch, the theme
93
+ const tokenSet = new Set([...Object.values(roles.light), ...Object.values(roles.dark),
94
+ ...Object.values(tokens.swatch || {}).map(hex6), "FFFFFF", "000000", ...declared]);
95
+
96
+ const families = [...new Set(tokens.faces.files.map((f) => f.family))];
97
+ const art = (role) => {
98
+ const f = tokens.art[role];
99
+ if (!f) throw new Error(`${name}: no art for role "${role}" (${Object.keys(tokens.art).join(", ")})`);
100
+ return path.join(assets, f);
101
+ };
102
+
103
+ return {
104
+ name, title: tokens.title, dir, tokens, roles, declared, tokenSet, families, fonts, assets, lib,
105
+ art, artUrl: (role) => url(art(role)),
106
+ asset: (file) => path.join(assets, file),
107
+ assetUrl: (file) => url(path.join(assets, file)),
108
+ glyph: (g) => readGlyph(assets, g),
109
+ faceFiles: tokens.faces.files.map((f) => ({ ...f, path: path.join(fonts, f.file) })),
110
+ css: () => brandCss(tokens, roles, fonts, extras),
111
+ };
112
+ }
113
+
114
+ function readGlyph(assets, g) {
115
+ const loose = path.join(assets, "glyphs", `${g}.svg`);
116
+ if (fs.existsSync(loose)) return fs.readFileSync(loose, "utf8");
117
+ const packed = path.join(assets, "glyphs", "glyphs.json");
118
+ if (fs.existsSync(packed)) { const all = JSON.parse(fs.readFileSync(packed, "utf8")); if (all[g]) return all[g]; }
119
+ throw new Error(`glyph "${g}" not in ${path.join(assets, "glyphs")}`);
120
+ }
121
+
122
+ function vars(r) {
123
+ return Object.entries(r).map(([k, v]) => `--${k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}:#${v}`).join(";");
124
+ }
125
+
126
+ function brandCss(tokens, roles, fonts, extras = {}) {
127
+ const faces = tokens.faces.files.map((f) =>
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
+ const F = tokens.faces, S = tokens.shape;
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
+ return `${faces}
132
+ .page{${vars(roles.light)};${Object.keys(extras).length ? vars(extras) + ";" : ""}${face("display")};${face("body")};${face("label")};--caps-label:${F.label.caps ? "uppercase" : "none"};
133
+ --radius-k:${S.radius};--card-stroke:${S.cardStroke}px;--emph-stroke:${S.emphStroke}px}
134
+ .page.dark{${vars(roles.dark)}}`;
135
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * lib/components.js — the vocabulary a page is composed from.
3
+ *
4
+ * Every piece that reviewed well in the Franklin Templeton case study, the
5
+ * aviation overview and the BofA talk, written once: the chrome (eyebrow,
6
+ * mark, footer), numbered sections, cards and the emphasis card, the chip
7
+ * strip with its arrows, the five-node flow, metered tables, quotes with
8
+ * attribution, figure rows, cover and closing pages, next-step cards.
9
+ *
10
+ * A component holds no value of its own. Colours come from the brand's roles
11
+ * (`--ink`, `--accent`), faces from `--face-display` / `--face-body` /
12
+ * `--face-label`, sizes from the surface's scale (`--t-h2`, `--m-gap`). The
13
+ * same markup therefore draws on either brand, light or dark, on a slide or
14
+ * a page — which is what lets a job be composed freely without leaving the
15
+ * system.
16
+ *
17
+ * `bind()` returns the helpers tied to one job: its brand, surface and
18
+ * content lines. A job's `pages(c)` composes with them and may add its own
19
+ * CSS on top; nothing stops it writing raw markup where no component fits.
20
+ */
21
+ import { esc } from "./content.js";
22
+ import { url } from "./brand.js";
23
+
24
+ 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>`;
25
+
26
+ export function bind({ brand, surface, job, content }) {
27
+ let n = 0;
28
+ const L = content.L;
29
+ const cls = (c) => (c ? ` ${c}` : "");
30
+ const list = (x) => (Array.isArray(x) ? x.join("") : String(x ?? ""));
31
+
32
+ /** `accent("Two Models", "Models")` → the phrase in the accent colour. */
33
+ const accent = (text, phrase) => (phrase ? esc(text).replace(esc(phrase), `<em>${esc(phrase)}</em>`) : esc(text));
34
+ const split = (ln, sep = " — ") => { const s = typeof ln === "number" ? L(ln) : ln; const i = s.indexOf(sep); return i < 0 ? [s, ""] : [s.slice(0, i), s.slice(i + sep.length)]; };
35
+ const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
36
+
37
+ const mark = (dark) => {
38
+ const invert = dark && brand.tokens.art.markInvertOnDark;
39
+ const src = brand.artUrl(dark && !invert ? "markOnDark" : "markOnLight");
40
+ return invert
41
+ ? `<div class="mark deco"><img class="w" src="${src}" alt=""><img class="star" src="${src}" alt=""></div>`
42
+ : `<div class="mark deco"><img src="${src}" alt=""></div>`;
43
+ };
44
+ const wordmark = (dark) => `<img class="wordmark deco" src="${brand.artUrl(dark ? "wordmarkOnDark" : "wordmarkOnLight")}" alt="Wand">`;
45
+
46
+ /**
47
+ * A page (a slide, a sheet, a card) with the chrome: the tag and eyebrow top
48
+ * left, the mark top right, the footer with the running line and the page
49
+ * number. `art` is a brand art role ("cover", "overlay", "closing") or a
50
+ * file path; `notes` become speaker notes in the .pptx.
51
+ */
52
+ const page = ({ eyebrow = "", dark = false, art = null, shade = dark, cls: k = "", body = "", notes = "", chrome = true } = {}) => {
53
+ n += 1;
54
+ const artSrc = art ? (/[\/.]/.test(art) && !brand.tokens.art[art] ? url(art) : brand.artUrl(art)) : null;
55
+ const eb = job.tag && eyebrow ? `${esc(job.tag)}<span class="bar">|</span>${esc(eyebrow)}` : esc(job.tag || eyebrow || "");
56
+ return `<section class="page ${dark ? "dark" : "light"}${cls(k)}" data-n="${n}"${notes ? ` data-notes="${esc(notes)}"` : ""}>
57
+ ${artSrc ? `<img class="art deco" src="${artSrc}" alt="">${shade ? `<div class="shade deco"></div>` : ""}` : ""}
58
+ ${chrome ? `<div class="eyebrow">${eb}</div>${mark(dark)}` : ""}
59
+ <div class="content">${body}</div>
60
+ ${chrome ? `<footer><span>${esc(job.footer || "")}</span><span class="pn">${String(n).padStart(2, "0")}</span></footer>` : ""}
61
+ </section>`;
62
+ };
63
+
64
+ const sec = (num, title, { stack = false } = {}) => `<div class="sec${stack ? " stack" : ""}"><span class="num">${esc(num)}</span><h2>${title}</h2></div>`;
65
+ const h = (tag) => (html, k = "") => `<${tag}${cls(k) ? ` class="${k}"` : ""}>${html}</${tag}>`;
66
+ const p = (k) => (html, extra = "") => `<p class="${k}${cls(extra)}">${html}</p>`;
67
+ const tag = (text, k = "") => `<div class="tag${cls(k)}">${esc(text)}</div>`;
68
+
69
+ const cols = (count, items, { stretch = true, cls: k = "" } = {}) =>
70
+ `<div class="cols cols-${count}${stretch ? " stretch" : ""}${cls(k)}">${list(items)}</div>`;
71
+
72
+ /** A card: optional tag, title (h4 by default), body html or paragraphs, an outcome line; `emph` flips it to the emphasis surface. */
73
+ const card = ({ tag: t, title, level = "h4", body = "", out = "", emph = false, cls: k = "", sm = false } = {}) =>
74
+ `<div class="card${emph ? " emph" : ""}${sm ? " sm" : ""}${cls(k)}">${t ? tag(t) : ""}${title ? `<${level}>${title}</${level}>` : ""}${Array.isArray(body) ? body.map((b) => `<p>${b}</p>`).join("") : body}${out ? `<p class="out">${out}</p>` : ""}</div>`;
75
+
76
+ const chip = (i, label, state = "") => `<span class="chip${cls(state)}"><span class="dot">${i + 1}</span><span>${esc(label)}</span></span>`;
77
+ /** The chips in a row, a solid arrow between each; `on` lights one and marks the earlier ones done. */
78
+ const strip = (labels, { on = -1 } = {}) => `<div class="strip">${labels.map((l, i) => chip(i, l, i === on ? "on" : on >= 0 && i < on ? "done" : "")).join(ARROW)}</div>`;
79
+ /** A phase heading: its chip, then its line. */
80
+ const phase = (i, label, line = "") => `<div class="phase">${chip(i, label)}${line ? `<h3>${esc(line)}</h3>` : ""}</div>`;
81
+
82
+ /** Five nodes with arrows: [who, verb, "a" | "p"] — agent or person. */
83
+ const flow = ({ label, nodes, note = "" }) => `<div class="flow"><div class="flabel">${esc(label)}</div><div class="nodes">${nodes.map(([who, verb, kind]) =>
84
+ `<div class="node ${kind || "p"}"><span class="who">${esc(who)}</span><span class="verb">${esc(verb)}</span></div>`).join(ARROW)}</div>${note ? `<p class="fnote">${note}</p>` : ""}</div>`;
85
+
86
+ /**
87
+ * A ruled table. `head`: cell strings. `rows`: arrays of cells; a cell is a
88
+ * string, or { v, meter: true, cls }. `emph`: the index of the row set in the accent.
89
+ */
90
+ const table = ({ head = [], rows = [], emph = -1, cls: k = "" }) => {
91
+ const cell = (c) => {
92
+ const o = typeof c === "object" && c ? c : { v: c };
93
+ const meter = o.meter ? `<div class="meter"><i style="width:${Math.max(0, Math.min(100, parseFloat(o.v) || 0))}%"></i></div>` : "";
94
+ return `<td${o.cls ? ` class="${o.cls}"` : ""}>${o.meter ? `<div class="val">${esc(o.v)}</div>` : esc(o.v)}${meter}</td>`;
95
+ };
96
+ return `<table class="grid${cls(k)}"><thead><tr>${head.map((x) => `<th>${esc(x)}</th>`).join("")}</tr></thead><tbody>${rows.map((r, i) =>
97
+ `<tr${i === emph ? ' class="emph"' : ""}>${r.map((c, j) => (j === 0 ? `<td class="rowhead">${esc(typeof c === "object" ? c.v : c)}</td>` : cell(c))).join("")}</tr>`).join("")}</tbody></table>`;
98
+ };
99
+
100
+ const who = (name, role) => `<div class="who"><div><div class="name">${esc(name)}</div><div class="role">${esc(role)}</div></div></div>`;
101
+ /** A quote card. `by` is "Name · Role" or [name, role]. */
102
+ const quote = ({ text, by, emph = false, cls: k = "" }) => {
103
+ const [name, role] = Array.isArray(by) ? by : split(by, " · ");
104
+ return `<div class="card q${emph ? " emph" : ""}${cls(k)}"><p>${esc(text)}</p>${who(name, role)}</div>`;
105
+ };
106
+
107
+ const stat = ({ v, k, hero = false }) => `<div class="stat${hero ? " hero" : ""}"><div class="v">${esc(v)}</div><div class="k">${esc(k)}</div></div>`;
108
+ const stats = (items) => `<div class="stats">${items.map(stat).join("")}</div>`;
109
+ const delta = (from, to) => `<div class="delta"><span class="from">${esc(from)}</span>${ARROW}<span class="to">${esc(to)}</span></div>`;
110
+
111
+ /** Next-step cards with arrows between; `when` is the timing line. */
112
+ const steps = (items) => `<div class="steps">${items.map(({ title, when, body, emph }, i) =>
113
+ `<div class="card step${emph ? " emph" : ""}"><div class="head"><span class="dot">${i + 1}</span><h5>${esc(title)}</h5></div>${when ? `<div class="when">${esc(when)}</div>` : ""}<p>${body}</p></div>`).join(ARROW)}</div>`;
114
+
115
+ const callout = ({ tag: t, text, emph = true }) => `<div class="card callout${emph ? " emph" : ""}">${t ? `<div class="tag">${esc(t)}</div>` : ""}<p>${text}</p></div>`;
116
+ const listOf = (items, k = "") => `<ul class="list${cls(k)}">${items.map((i) => `<li>${i}</li>`).join("")}</ul>`;
117
+
118
+ /** A cover page: label, display title (html), lede, body, meta line, anything else, on brand art. */
119
+ const cover = ({ label, title, lede = "", body = "", meta = "", extra = "", art = "cover", dark = true, eyebrow = "", cls: k = "" }) =>
120
+ page({ dark, art, eyebrow, cls: `cover${extra ? " split" : ""}${cls(k)}`, body: `<div class="top">${label ? `<div class="label">${esc(label)}</div>` : ""}<h1>${title}</h1>${lede ? `<p class="lede">${lede}</p>` : ""}${body ? `<p class="cover-body">${body}</p>` : ""}${meta ? `<p class="meta">${esc(meta)}</p>` : ""}</div>${extra ? `<div class="bottom">${extra}</div>` : ""}` });
121
+
122
+ /** A closing page: the lockup (pair line, title, url) over brand art, with an optional heading and line above it. */
123
+ const closing = ({ pair, title, url: u = "www.wand.ai", heading = "", lede = "", eyebrow = "", art = "closing", dark = true, cls: k = "" }) =>
124
+ page({ dark, art, eyebrow, cls: `back${cls(k)}`, body: `${heading ? `<h1>${heading}</h1>` : ""}${lede ? `<p class="lede">${lede}</p>` : ""}<div class="lockup"><div class="pair">${esc(pair)}</div><div class="title">${esc(title)}</div><div class="url">${esc(u)}</div></div>` });
125
+
126
+ /** A big outlined numeral in a dark panel on the right (the deck's chapter pages). */
127
+ const chapter = (num) => `<div class="chapter deco"><span>${esc(num)}</span></div>`;
128
+
129
+ const img = (src, k = "", alt = "") => `<img class="img${cls(k)}" src="${/^(file|https?|data):/.test(src) ? src : url(src)}" alt="${esc(alt)}">`;
130
+ const icon = (name, k = "") => brand.glyph(name).replace("<svg", `<svg class="icon deco${cls(k)}" aria-hidden="true"`);
131
+ const tile = (name) => `<div class="tile deco">${icon(name)}</div>`;
132
+
133
+ return {
134
+ L, lines: content.lines, esc, accent, split, cap, raw: (s) => s, ARROW, arrow: ARROW,
135
+ page, sec, h1: h("h1"), h2: h("h2"), h3: h("h3"), h4: h("h4"), h5: h("h5"),
136
+ lead: p("lead"), body: p("body"), note: p("note"), small: p("body sm"), tag, p: (html, k = "") => `<p${cls(k) ? ` class="${k}"` : ""}>${html}</p>`,
137
+ cols, card, chip, strip, phase, flow, table, who, quote, stat, stats, delta, steps, callout, list: listOf,
138
+ cover, closing, chapter, img, icon, tile, mark, wordmark,
139
+ asset: brand.assetUrl, art: brand.artUrl,
140
+ brand: brand.name, surface: surface.name, count: () => n,
141
+ };
142
+ }
143
+
144
+ /** The components' CSS. Every value is a custom property from the brand or the surface. */
145
+ export const COMPONENTS_CSS = `
146
+ *{box-sizing:border-box;margin:0;padding:0}
147
+ html,body{background:#E9E7EE}
148
+ .page{position:relative;overflow:hidden;break-after:page;background:var(--bg);color:var(--ink);font-family:var(--face-body);font-weight:var(--w-body);
149
+ letter-spacing:var(--tr-body);line-height:var(--lh-body);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
150
+ .art{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:38% 50%}
151
+ .shade{position:absolute;inset:0;background:var(--bg);opacity:.3}
152
+ .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);
153
+ letter-spacing:.22em;text-transform:var(--caps-label);color:var(--muted);line-height:1.2}
154
+ .dark .eyebrow{color:var(--ink)}
155
+ .eyebrow .bar{margin:0 .9em;opacity:.6}
156
+ .mark{position:absolute;right:var(--x);top:var(--c-mark-top);width:var(--c-mark-w);height:var(--c-mark-h)}
157
+ .mark img{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:contain}
158
+ .mark .w{filter:brightness(0) invert(1)}.mark .star{clip-path:inset(0 0 60% 58%)}
159
+ .wordmark{position:absolute;right:var(--x);top:var(--c-mark-top);height:calc(var(--c-mark-h) * .62);width:auto}
160
+ .content{position:absolute;left:var(--x);right:var(--x);top:var(--top);bottom:var(--bottom);display:flex;flex-direction:column;gap:var(--m-gap)}
161
+ footer{position:absolute;left:var(--x);right:var(--x);bottom:var(--c-foot-bottom);display:flex;justify-content:space-between;align-items:center;
162
+ font-size:var(--c-foot-size);color:var(--muted);border-top:var(--c-foot-rule) solid var(--rule);padding-top:calc(var(--c-foot-rule) * 10);line-height:1.2}
163
+ footer .pn{font-weight:500}
164
+ /* type */
165
+ 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)}
166
+ 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}
167
+ h1 em,h2 em,h3 em,h4 em,.display em,.lede em{font-style:normal;color:var(--accent-text)}
168
+ .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}
169
+ .label.sm{font-size:calc(var(--t-label) * .8)}
170
+ .lead{font-size:var(--t-lead);line-height:1.5;color:var(--ink)}
171
+ .body{font-size:var(--t-body);line-height:var(--lh-body);color:var(--text)}
172
+ .body.sm,.small{font-size:var(--t-sm);line-height:1.55;color:var(--text)}
173
+ .note{font-size:var(--t-note);line-height:1.5;color:var(--soft)}
174
+ .note b,.fnote b,.lede b{font-weight:600;color:var(--accent-text)}
175
+ .tag{font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-tag);letter-spacing:.18em;text-transform:var(--caps-label);color:var(--label);margin-bottom:.55em;line-height:1.2}
176
+ .wide{max-width:86%}
177
+ b,strong{font-weight:600}
178
+ /* sections */
179
+ .sec{display:flex;align-items:baseline;gap:1em;padding-top:var(--m-sec-top);border-top:var(--m-stroke) solid var(--rule)}
180
+ .sec:first-child{border-top:0;padding-top:0}
181
+ .sec.stack{flex-direction:column;align-items:flex-start;gap:.35em}
182
+ .sec .num{font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-num);letter-spacing:.2em;color:var(--label)}
183
+ .sec + .lead,.sec + .body{margin-top:calc(var(--m-gap) * -.3)}
184
+ /* grids */
185
+ .cols{display:grid;gap:var(--m-col-gap)}
186
+ .cols-2{grid-template-columns:1fr 1fr}.cols-3{grid-template-columns:repeat(3,1fr)}.cols-4{grid-template-columns:repeat(4,1fr)}.cols-5{grid-template-columns:repeat(5,1fr)}
187
+ .cols-12{grid-template-columns:1.35fr 1fr}.cols-21{grid-template-columns:1fr 1.35fr}
188
+ .stretch{align-items:stretch}.span-2{grid-column:span 2}.span-3{grid-column:span 3}
189
+ .rail{display:grid;grid-template-columns:3fr 9fr;gap:var(--m-col-gap)}
190
+ /* cards */
191
+ .card{background:var(--paper);border:var(--card-stroke) solid var(--stroke);border-radius:calc(var(--m-radius) * var(--radius-k));padding:var(--m-card-pad)}
192
+ .card.sm{padding:var(--m-card-pad-sm)}
193
+ .card.emph{background:var(--emph);border:var(--emph-stroke) solid var(--emph-accent);color:var(--emph-ink)}
194
+ .card.emph p,.card.emph .body,.card.emph .role,.card.emph .lead{color:var(--emph-text)}
195
+ .card.emph h3,.card.emph h4,.card.emph h5,.card.emph .name,.card.emph .lead-in,.card.emph .q p{color:var(--emph-ink)}
196
+ .card.emph .tag,.card.emph .out,.card.emph .when,.card.emph em{color:var(--emph-accent)}
197
+ .card.centred{display:flex;flex-direction:column;justify-content:center}
198
+ .card p{font-size:var(--t-sm);line-height:1.5;color:var(--text)}
199
+ .card .body{font-size:var(--t-body);line-height:var(--lh-body)}.card .body.sm{font-size:var(--t-sm);line-height:1.55}
200
+ .card .lead{font-size:var(--t-lead)}
201
+ .card h3{margin-bottom:.3em}.card h4{margin-bottom:.35em}.card h5{margin-bottom:.4em}
202
+ .card .sub{font-size:var(--t-sm);font-weight:500;color:var(--accent-text);margin-bottom:.5em}
203
+ .card.emph .sub{color:var(--emph-accent)}
204
+ .card .out{margin-top:auto;padding-top:.6em;font-weight:500;color:var(--accent-text)}
205
+ .card.fn,.card.flex{display:flex;flex-direction:column}
206
+ .card.fn h5{font-family:var(--face-label);font-weight:var(--w-label);font-size:calc(var(--t-tag) * 1.1);letter-spacing:.12em;text-transform:var(--caps-label);color:var(--ink);margin-bottom:.6em}
207
+ .card.emph.fn h5{color:var(--emph-ink)}
208
+ .callout{padding:var(--m-card-pad)}
209
+ .callout p{font-size:calc(var(--t-lead) * .9);font-weight:500;line-height:1.45}
210
+ .lead-in,.card .lead-in{font-size:calc(var(--t-lead) * .92);font-weight:500;line-height:1.45}
211
+ .role-tag{margin:.4em 0 .7em;font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-tag);letter-spacing:.18em;text-transform:var(--caps-label);color:var(--soft)}
212
+ .card.emph .role-tag{color:var(--emph-text)}
213
+ /* chips, arrows, phases */
214
+ .arrow{width:var(--m-arrow);height:var(--m-arrow);flex:none;fill:var(--emph)}
215
+ .dark .arrow{fill:var(--accent)}
216
+ .strip{display:flex;align-items:center;gap:calc(var(--m-arrow) * .9)}
217
+ .strip .chip{flex:1}
218
+ .chip{display:flex;align-items:center;gap:.6em;padding:.5em 1em .5em .45em;border:var(--m-stroke) solid var(--stroke);border-radius:999px;background:var(--paper);
219
+ font-size:calc(var(--t-sm) * .95);font-weight:500;color:var(--ink);line-height:1.2}
220
+ .chip .dot{width:var(--m-chip);height:var(--m-chip);border-radius:50%;background:var(--emph);color:var(--emph-ink);font-size:calc(var(--t-sm) * .8);font-weight:600;display:flex;align-items:center;justify-content:center;flex:none}
221
+ .chip.done{color:var(--accent-text)}
222
+ .chip.on{background:var(--emph);border-color:var(--emph);color:var(--emph-ink)}
223
+ .chip.on .dot{background:var(--emph-ink);color:var(--emph)}
224
+ .phase{display:flex;align-items:center;gap:1em}
225
+ .phase .chip{padding:.4em .9em .4em .4em}
226
+ .phase h3{margin:0}
227
+ /* the five-node flow */
228
+ .flow{display:flex;flex-direction:column;gap:.55em}
229
+ .flabel{font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-tag);letter-spacing:.18em;text-transform:var(--caps-label);color:var(--soft)}
230
+ .flow .nodes{display:flex;align-items:center;gap:calc(var(--m-arrow) * .8)}
231
+ .node{flex:1;display:flex;flex-direction:column;gap:.15em;padding:.7em 1em;border-radius:calc(var(--m-radius) * .7 * var(--radius-k));border:var(--m-stroke) solid var(--stroke);background:var(--paper)}
232
+ .node .who{font-family:var(--face-label);font-weight:var(--w-label);font-size:calc(var(--t-tag) * .92);letter-spacing:.16em;text-transform:var(--caps-label);color:var(--soft);display:block;border:0;padding:0}
233
+ .node .verb{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-sm);color:var(--ink);letter-spacing:var(--tr-display)}
234
+ .node.a{background:var(--emph);border-color:var(--emph)}.node.a .who{color:var(--emph-accent)}.node.a .verb{color:var(--emph-ink)}
235
+ .fnote{font-size:var(--t-sm);line-height:1.4;color:var(--text)}
236
+ /* tables */
237
+ table.grid{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
238
+ .grid th{text-align:left;font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-tag);letter-spacing:.16em;text-transform:var(--caps-label);color:var(--ink);padding:0 .7em .8em 0;border-bottom:var(--m-stroke) solid var(--rule)}
239
+ .grid td{font-size:var(--t-body);padding:.8em .7em .8em 0;border-bottom:1px solid var(--rule);vertical-align:middle;color:var(--ink)}
240
+ .grid .rowhead{font-size:calc(var(--t-body) * .95);color:var(--text)}
241
+ .grid .val{font-weight:500}
242
+ .grid tr.emph .val,.grid tr.emph .rowhead{color:var(--accent-text);font-weight:600}
243
+ .grid td.strong{font-weight:600}.grid td.accent{font-weight:600;color:var(--accent-text)}
244
+ .meter{margin-top:.4em;height:3px;border-radius:2px;background:var(--track);width:82%}
245
+ .meter i{display:block;height:100%;border-radius:2px;background:var(--accent)}
246
+ .grid tr.emph .meter i{background:var(--accent-text)}
247
+ /* quotes */
248
+ .q{display:flex;flex-direction:column;justify-content:space-between;gap:1.2em}
249
+ .q p{font-size:var(--t-quote);line-height:1.6;color:var(--ink)}
250
+ .who{display:flex;align-items:center;gap:.9em;border-top:1px solid var(--rule);padding-top:1em}
251
+ .card.emph .who{border-top-color:var(--emph-accent)}
252
+ .who .name{font-size:var(--t-name);font-weight:600;color:var(--ink)}
253
+ .who .role{font-size:var(--t-role);color:var(--soft);margin-top:.15em}
254
+ /* figures */
255
+ .stats{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:var(--m-col-gap)}
256
+ .stat{border:var(--card-stroke) solid var(--stroke);border-radius:calc(var(--m-radius) * var(--radius-k));background:var(--paper);padding:1.2em 1.3em 1.1em;display:flex;flex-direction:column;justify-content:flex-end;gap:.45em}
257
+ .stat .v{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-stat);line-height:1;letter-spacing:-.03em;color:var(--ink)}
258
+ .stat.hero .v{font-size:var(--t-hero)}
259
+ .stat .k{font-size:var(--t-sm);font-weight:500;color:var(--text)}
260
+ .delta{display:flex;align-items:center;gap:.5em;margin:.2em 0 .45em}
261
+ .delta .from{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-h3);color:var(--soft)}
262
+ .delta .to{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-h3);color:var(--accent-text)}
263
+ .delta .arrow{fill:var(--accent)}
264
+ /* next steps */
265
+ .steps{display:flex;align-items:stretch;gap:.7em}
266
+ .steps .arrow{align-self:center}
267
+ .step{flex:1}
268
+ .step .head{display:flex;align-items:center;gap:.6em}
269
+ .step h5{margin:0}
270
+ .step .dot{width:var(--m-chip);height:var(--m-chip);border-radius:50%;background:var(--emph);color:var(--emph-ink);font-size:calc(var(--t-sm) * .8);font-weight:600;display:flex;align-items:center;justify-content:center;flex:none}
271
+ .step.emph .dot{background:var(--emph-ink);color:var(--emph)}
272
+ .step .when{font-size:var(--t-note);font-weight:500;color:var(--accent-text);margin:.5em 0 .3em}
273
+ /* lists */
274
+ .list{list-style:none;display:flex;flex-direction:column;gap:.45em;font-size:var(--t-body);color:var(--text);line-height:1.5}
275
+ .list li{display:flex;gap:.6em}
276
+ .list li::before{content:"";flex:none;width:.4em;height:.4em;border-radius:50%;background:var(--accent);margin-top:.55em}
277
+ .card .list{font-size:var(--t-sm)}
278
+ /* cover and closing */
279
+ .cover .content{justify-content:center;gap:0}
280
+ .cover.split .content{justify-content:space-between}
281
+ .cover .top{display:flex;flex-direction:column;gap:1.3em}
282
+ .cover h1{font-size:var(--t-display);line-height:1.02;letter-spacing:-.03em}
283
+ .lede{font-size:var(--t-lede);font-weight:500;line-height:1.45;color:var(--ink);max-width:88%}
284
+ .cover-body{font-size:var(--t-body);line-height:1.6;color:var(--text);max-width:80%}
285
+ .meta{font-size:var(--t-meta);color:var(--text);padding-top:1em;border-top:1px solid var(--rule);line-height:1.4}
286
+ .cover .bottom{display:flex;flex-direction:column;gap:1em}
287
+ .back .content{justify-content:center;gap:1.4em}
288
+ .lockup{padding-top:1.4em;border-top:1px solid var(--rule);display:flex;flex-direction:column;gap:.5em}
289
+ .lockup .pair{font-family:var(--face-label);font-weight:var(--w-label);font-size:var(--t-tag);letter-spacing:.22em;text-transform:var(--caps-label);color:var(--accent-text)}
290
+ .lockup .title{font-family:var(--face-display);font-weight:var(--w-display);font-size:var(--t-h1md);letter-spacing:var(--tr-display);line-height:1.1}
291
+ .lockup .url{font-size:var(--t-sm);color:var(--text)}
292
+ .dark .rule,.dark .meta,.dark .lockup,.dark .who,.dark footer{border-color:var(--rule)}
293
+ /* chapter panel */
294
+ .chapter{position:absolute;right:0;top:0;bottom:0;width:31%;border-radius:calc(var(--m-radius) * 1.15 * var(--radius-k));overflow:hidden;
295
+ background:radial-gradient(120% 90% at 85% 10%, var(--emph-accent) -40%, var(--emph) 55%, color-mix(in srgb, var(--emph), #000 35%) 100%)}
296
+ .chapter span{position:absolute;right:9%;bottom:-.12em;font-family:var(--face-display);font-weight:var(--w-display);font-size:calc(var(--t-display) * 6);line-height:1;letter-spacing:-.05em;
297
+ color:transparent;-webkit-text-stroke:2px var(--emph-accent);opacity:.6}
298
+ .has-chapter .left{width:62%;display:flex;flex-direction:column;gap:var(--m-gap)}
299
+ /* images, icons */
300
+ .img{display:block;max-width:100%}
301
+ .figure{border-radius:calc(var(--m-radius) * var(--radius-k));overflow:hidden}
302
+ .icon{width:1.25em;height:1.25em;stroke:currentColor;fill:none}
303
+ .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}
304
+ .card.emph .tile{background:var(--emph-accent);color:var(--emph)}
305
+ /* room */
306
+ .roomy .content{gap:calc(var(--m-gap) * 1.3)}
307
+ .roomy .cols{margin:auto 0}
308
+ .roomy .lead{font-size:calc(var(--t-lead) * 1.15)}
309
+ .roomy .card{padding:calc(var(--m-gap) * 1.5) calc(var(--m-gap) * 1.7)}
310
+ .centre{text-align:center;align-items:center}
311
+ .grow{flex:1}
312
+ .spread{justify-content:space-between}
313
+ `;
package/lib/content.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * lib/content.js — the words, kept apart from the design.
3
+ *
4
+ * A job's copy lives in a lines file (`content.txt`): one source string per
5
+ * line, verbatim, in the source's order. The page is composed by line number,
6
+ * so no string can drift from the source without a trace, and the coverage
7
+ * gate can read every line back off the page.
8
+ *
9
+ * Departures are declared, not hidden:
10
+ * edits { 44: [["needle", "replacement"]] } applied before the page sees the line, must match
11
+ * cut [6, 7] left out on purpose; coverage skips them
12
+ * split " — " a line the layout sets in parts (label — body)
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ export const esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
18
+
19
+ export function loadContent(jobDir, job) {
20
+ if (!job.content) return { lines: [], L: () => { throw new Error("this job has no content file"); }, cut: new Set(), edited: 0, has: false };
21
+ const file = path.resolve(jobDir, job.content);
22
+ if (!fs.existsSync(file)) throw new Error(`content file not found: ${file}`);
23
+ const lines = fs.readFileSync(file, "utf8").replace(/\r/g, "").split("\n");
24
+ let edited = 0;
25
+ for (const [k, subs] of Object.entries(job.edits || {})) {
26
+ const i = +k - 1;
27
+ if (!(i in lines)) throw new Error(`edits: no line ${k}`);
28
+ for (const [a, b] of subs) {
29
+ if (!lines[i].includes(a)) throw new Error(`edit ${k}: "${a}" not found in: ${lines[i].slice(0, 80)}`);
30
+ lines[i] = lines[i].replace(a, b); edited++;
31
+ }
32
+ }
33
+ const L = (n) => {
34
+ if (!(n - 1 in lines)) throw new Error(`L(${n}): the content file has ${lines.length} lines`);
35
+ return lines[n - 1];
36
+ };
37
+ return { lines, L, cut: new Set(job.cut || []), edited, has: true, file };
38
+ }
39
+
40
+ /** Whitespace-free, case-folded, with the characters that get swapped in transit normalised. */
41
+ export function norm(s) {
42
+ return String(s).normalize("NFKC")
43
+ .replace(/[‘’]/g, "'").replace(/[“”]/g, '"')
44
+ .replace(/[–—−]/g, "-").replace(/ /g, " ").replace(/…/g, "...")
45
+ .replace(/[\s­]+/g, "").toLowerCase();
46
+ }
47
+
48
+ /**
49
+ * Which lines are not on the page. A line passes whole, or in every part when
50
+ * split on one of the job's splitters (the layout sets "Finance — the line" as
51
+ * a card title over its body).
52
+ */
53
+ export function coverage(content, pageText, splitters = [" — ", " · ", " · "]) {
54
+ const hay = norm(pageText);
55
+ const miss = [];
56
+ content.lines.forEach((s, i) => {
57
+ const n = i + 1;
58
+ if (!s.trim() || content.cut.has(n)) return;
59
+ if (hay.includes(norm(s))) return;
60
+ const sp = splitters.find((x) => s.includes(x));
61
+ if (sp && s.split(sp).every((p) => !p.trim() || hay.includes(norm(p)))) return;
62
+ miss.push(n);
63
+ });
64
+ const total = content.lines.filter((s, i) => s.trim() && !content.cut.has(i + 1)).length;
65
+ return { miss, total, found: total - miss.length };
66
+ }