wand-decks-kit 0.8.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 (170) hide show
  1. package/PROMPT.md +461 -0
  2. package/README.md +137 -0
  3. package/START-HERE.md +98 -0
  4. package/assets/README.md +40 -0
  5. package/assets/_icon-contact-sheet.png +0 -0
  6. package/assets/bg-cover-dark.png +0 -0
  7. package/assets/bg-overlay-dark.png +0 -0
  8. package/assets/bg-statement-dark.png +0 -0
  9. package/assets/glyphs/README.md +29 -0
  10. package/assets/glyphs/activity.svg +15 -0
  11. package/assets/glyphs/atom.svg +17 -0
  12. package/assets/glyphs/badge-check.svg +16 -0
  13. package/assets/glyphs/bell.svg +16 -0
  14. package/assets/glyphs/blocks.svg +16 -0
  15. package/assets/glyphs/book-open.svg +16 -0
  16. package/assets/glyphs/bot.svg +20 -0
  17. package/assets/glyphs/boxes.svg +26 -0
  18. package/assets/glyphs/brain.svg +23 -0
  19. package/assets/glyphs/briefcase.svg +16 -0
  20. package/assets/glyphs/building-2.svg +21 -0
  21. package/assets/glyphs/calendar.svg +18 -0
  22. package/assets/glyphs/chart-column.svg +18 -0
  23. package/assets/glyphs/chart-line.svg +16 -0
  24. package/assets/glyphs/chart-pie.svg +16 -0
  25. package/assets/glyphs/circle-check.svg +16 -0
  26. package/assets/glyphs/clipboard-check.svg +17 -0
  27. package/assets/glyphs/clock.svg +16 -0
  28. package/assets/glyphs/cloud.svg +15 -0
  29. package/assets/glyphs/coins.svg +18 -0
  30. package/assets/glyphs/compass.svg +16 -0
  31. package/assets/glyphs/cpu.svg +24 -0
  32. package/assets/glyphs/database.svg +17 -0
  33. package/assets/glyphs/eye.svg +16 -0
  34. package/assets/glyphs/file-check.svg +17 -0
  35. package/assets/glyphs/filter.svg +15 -0
  36. package/assets/glyphs/fingerprint.svg +23 -0
  37. package/assets/glyphs/flag.svg +16 -0
  38. package/assets/glyphs/gauge.svg +16 -0
  39. package/assets/glyphs/gavel.svg +19 -0
  40. package/assets/glyphs/git-branch.svg +18 -0
  41. package/assets/glyphs/globe.svg +17 -0
  42. package/assets/glyphs/graduation-cap.svg +17 -0
  43. package/assets/glyphs/handshake.svg +19 -0
  44. package/assets/glyphs/hexagon.svg +15 -0
  45. package/assets/glyphs/index.json +175 -0
  46. package/assets/glyphs/infinity.svg +15 -0
  47. package/assets/glyphs/key.svg +17 -0
  48. package/assets/glyphs/landmark.svg +20 -0
  49. package/assets/glyphs/layers.svg +17 -0
  50. package/assets/glyphs/lightbulb.svg +17 -0
  51. package/assets/glyphs/link.svg +16 -0
  52. package/assets/glyphs/list-checks.svg +19 -0
  53. package/assets/glyphs/lock.svg +16 -0
  54. package/assets/glyphs/map.svg +17 -0
  55. package/assets/glyphs/megaphone.svg +16 -0
  56. package/assets/glyphs/message-square.svg +15 -0
  57. package/assets/glyphs/milestone.svg +17 -0
  58. package/assets/glyphs/network.svg +19 -0
  59. package/assets/glyphs/orbit.svg +19 -0
  60. package/assets/glyphs/package.svg +18 -0
  61. package/assets/glyphs/plug.svg +18 -0
  62. package/assets/glyphs/puzzle.svg +15 -0
  63. package/assets/glyphs/radar.svg +22 -0
  64. package/assets/glyphs/receipt.svg +17 -0
  65. package/assets/glyphs/refresh-cw.svg +18 -0
  66. package/assets/glyphs/rocket.svg +18 -0
  67. package/assets/glyphs/route.svg +17 -0
  68. package/assets/glyphs/scale.svg +19 -0
  69. package/assets/glyphs/search.svg +16 -0
  70. package/assets/glyphs/server.svg +18 -0
  71. package/assets/glyphs/settings.svg +16 -0
  72. package/assets/glyphs/share-2.svg +19 -0
  73. package/assets/glyphs/shield-check.svg +16 -0
  74. package/assets/glyphs/shuffle.svg +19 -0
  75. package/assets/glyphs/sparkles.svg +19 -0
  76. package/assets/glyphs/square-stack.svg +17 -0
  77. package/assets/glyphs/target.svg +17 -0
  78. package/assets/glyphs/trending-up.svg +16 -0
  79. package/assets/glyphs/triangle-alert.svg +17 -0
  80. package/assets/glyphs/trophy.svg +20 -0
  81. package/assets/glyphs/users.svg +18 -0
  82. package/assets/glyphs/wallet.svg +16 -0
  83. package/assets/glyphs/wand-sparkles.svg +22 -0
  84. package/assets/glyphs/workflow.svg +17 -0
  85. package/assets/glyphs/zap.svg +15 -0
  86. package/assets/icons/icon-11.png +0 -0
  87. package/assets/icons/icon-12.png +0 -0
  88. package/assets/icons/icon-13.png +0 -0
  89. package/assets/icons/icon-14.png +0 -0
  90. package/assets/icons/icon-15.png +0 -0
  91. package/assets/icons/icon-16.png +0 -0
  92. package/assets/icons/icon-17.png +0 -0
  93. package/assets/icons/icon-18.png +0 -0
  94. package/assets/icons/icon-20.png +0 -0
  95. package/assets/icons/icon-23.png +0 -0
  96. package/assets/icons/icon-24.png +0 -0
  97. package/assets/icons/icon-25.png +0 -0
  98. package/assets/icons/icon-27.png +0 -0
  99. package/assets/icons/icon-28.png +0 -0
  100. package/assets/icons/icon-30.png +0 -0
  101. package/assets/icons/icon-31.png +0 -0
  102. package/assets/icons/icon-32.png +0 -0
  103. package/assets/icons/icon-33.png +0 -0
  104. package/assets/icons/icon-34.png +0 -0
  105. package/assets/icons/icon-35.png +0 -0
  106. package/assets/icons/icon-36.png +0 -0
  107. package/assets/icons/icon-37.png +0 -0
  108. package/assets/icons/icon-39.png +0 -0
  109. package/assets/icons/icon-40.png +0 -0
  110. package/assets/icons/icon-41.png +0 -0
  111. package/assets/icons/icon-43.png +0 -0
  112. package/assets/icons/icon-44.png +0 -0
  113. package/assets/icons/icon-45.png +0 -0
  114. package/assets/icons/icon-46.png +0 -0
  115. package/assets/icons/icon-48.png +0 -0
  116. package/assets/icons/icon-49.png +0 -0
  117. package/assets/icons/icon-50.png +0 -0
  118. package/assets/icons/icon-51.png +0 -0
  119. package/assets/icons/icon-52.png +0 -0
  120. package/assets/icons/icon-53.png +0 -0
  121. package/assets/icons/icon-9.png +0 -0
  122. package/assets/mark-dark.png +0 -0
  123. package/assets/mark-white.png +0 -0
  124. package/assets/orb-cover.png +0 -0
  125. package/assets/wordmark-dark.png +0 -0
  126. package/assets/wordmark-white.png +0 -0
  127. package/build.js +192 -0
  128. package/build_example.js +91 -0
  129. package/build_infinite.js +283 -0
  130. package/docs/LAYOUTS.md +86 -0
  131. package/docs/PIPELINE.md +116 -0
  132. package/docs/SPEC.md +89 -0
  133. package/embed_fonts.py +193 -0
  134. package/examples/template.json +164 -0
  135. package/fonts/Geist-Bold.ttf +0 -0
  136. package/fonts/Geist-BoldItalic.ttf +0 -0
  137. package/fonts/Geist-ExtraBold.ttf +0 -0
  138. package/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  139. package/fonts/Geist-Italic.ttf +0 -0
  140. package/fonts/Geist-Light.ttf +0 -0
  141. package/fonts/Geist-LightItalic.ttf +0 -0
  142. package/fonts/Geist-Medium.ttf +0 -0
  143. package/fonts/Geist-MediumItalic.ttf +0 -0
  144. package/fonts/Geist-Regular.ttf +0 -0
  145. package/fonts/Geist-SemiBold.ttf +0 -0
  146. package/fonts/Geist-SemiBoldItalic.ttf +0 -0
  147. package/lib/README.md +23 -0
  148. package/lib/brand.js +51 -0
  149. package/lib/glyphs.js +87 -0
  150. package/lib/layouts_client.js +212 -0
  151. package/lib/layouts_consult.js +301 -0
  152. package/lib/layouts_dense.js +361 -0
  153. package/lib/measure.js +109 -0
  154. package/lib/pdf.js +517 -0
  155. package/lib/plan.js +305 -0
  156. package/lib/render_pdf.js +203 -0
  157. package/lib/schema.js +399 -0
  158. package/lib/shared.js +15 -0
  159. package/lib/trace.js +88 -0
  160. package/lib/ttf.py +167 -0
  161. package/lib/wandxml.py +37 -0
  162. package/package.json +17 -0
  163. package/preview/preview.css +112 -0
  164. package/preview/preview.html +24 -0
  165. package/preview/preview.js +169 -0
  166. package/qa_coverage.py +120 -0
  167. package/requirements.txt +9 -0
  168. package/tools/extract.py +199 -0
  169. package/wand.js +397 -0
  170. package/wand_kit.js +1016 -0
package/wand_kit.js ADDED
@@ -0,0 +1,1016 @@
1
+ /**
2
+ * wand_kit.js — the Wand deck design system, as code.
3
+ * Kit version: see VERSION below. Spec: ../PROMPT.md
4
+ *
5
+ * This file replaces `wand-template.pptx`. Geometry is measured from
6
+ * Infinite_Labor__by_Rotem__July_2026.pptx, not transcribed from the spec.
7
+ *
8
+ * Canvas is 10.000" x 5.625" (pptxgenjs LAYOUT_16x9). Do not change it.
9
+ */
10
+
11
+ const path = require("path");
12
+ const M = require("./lib/shared").measure;
13
+ const ASSETS = require("./lib/brand").resolve("assets");
14
+ const G = require("./lib/shared").glyphs;
15
+
16
+ /**
17
+ * Shrink text to fit its box, measured against the real Geist face.
18
+ * §9: LibreOffice substitutes DejaVu, so visual QA cannot be trusted for fit.
19
+ * This is measured, so it can be.
20
+ */
21
+ function autoSize(text, w, h, opts) {
22
+ return M.fitSize(String(text), w, h, opts);
23
+ }
24
+
25
+ /* ------------------------------------------------------------------ tokens */
26
+
27
+ const T = {
28
+ accent: "8B7CF6",
29
+ accentDeep: "6C5CE7",
30
+ accentSoft: "A99CF8",
31
+ accentPale: "C4B5F4",
32
+
33
+ bgDark: "0F0C26",
34
+ inkDark: "F3F1FA",
35
+ white: "FFFFFF",
36
+ bodyDark: "B7B0DC",
37
+ bodyDarkAlt: "CCC0F2",
38
+ cardStroke: "2E2757",
39
+ cardSolid: "2C2460",
40
+ cardMuted: "241D4F",
41
+ cardMutedStroke: "4A3F8C",
42
+ cardEmphFlat: "352B70",
43
+ band: "0F0C26",
44
+ footerDark: "F3F3F3",
45
+
46
+ bgLight: "F8F6FB",
47
+ inkLight: "1D1A33",
48
+ bodyLight: "5C5779",
49
+ mutedLight: "555077",
50
+ cardLight: "FFFFFF",
51
+ surface: "EBE8F8",
52
+ track: "E9E6F5",
53
+ divider: "E4E0F2",
54
+ footerLight:"999999",
55
+ };
56
+
57
+ const F = {
58
+ reg: "Geist",
59
+ med: "Geist Medium",
60
+ semi: "Geist SemiBold",
61
+ xbold: "Geist ExtraBold",
62
+ light: "Geist Light",
63
+ };
64
+
65
+ /* Column presets — never invent new ones. */
66
+ const COLS = {
67
+ 2: { w: 4.25, x: [0.37, 5.38] },
68
+ "3wide": { w: 2.90, x: [0.38, 3.55, 6.72] },
69
+ "3std": { w: 2.84, x: [0.37, 3.58, 6.78] },
70
+ "3tall": { w: 2.72, x: [0.37, 3.64, 6.90] },
71
+ 4: { w: 2.09, x: [0.37, 2.73, 5.15, 7.54] },
72
+ "4stat": { w: 2.09, x: [0.37, 2.78, 5.15, 7.54] },
73
+ 5: { w: 1.78, x: [0.41, 2.29, 4.18, 6.06, 7.94] },
74
+ 6: { w: 1.50, x: [0.41, 1.96, 3.51, 5.06, 6.61, 8.16] },
75
+ full: { w: 9.25, x: [0.37] },
76
+ };
77
+
78
+ /* roundRect corner radius is relative to the shorter side — recompute per card. */
79
+ const radius = (w, h) => 0.10 / Math.min(w, h);
80
+
81
+ /* Theme resolver. Every layout takes `dark` and reads colours through this. */
82
+ const theme = (dark) => dark
83
+ ? { ink: T.inkDark, body: T.bodyDark, alt: T.bodyDarkAlt,
84
+ cardFill: { color: "9481FF", transparency: 88 },
85
+ cardLine: { color: T.cardStroke, width: 0.75 },
86
+ tile: T.accent, tileGlyphDark: false, foot: T.footerDark, eyebrow: T.accentPale }
87
+ : { ink: T.inkLight, body: T.bodyLight, alt: T.mutedLight,
88
+ cardFill: { color: T.cardLight },
89
+ cardLine: { color: T.cardLight, width: 0.75 },
90
+ tile: T.surface, tileGlyphDark: true, foot: T.footerLight, eyebrow: T.accentDeep };
91
+
92
+ /* ------------------------------------------------------------------ chrome */
93
+
94
+ function background(slide, dark) {
95
+ if (dark) {
96
+ slide.background = { color: T.bgDark };
97
+ slide.addImage({ path: `${ASSETS}/bg-overlay-dark.png`, x: 0, y: 0, w: 10, h: 5.625 });
98
+ } else {
99
+ slide.background = { color: T.bgLight };
100
+ }
101
+ }
102
+
103
+ /** Tag + logo mark. Present on every slide except cover and closing. */
104
+ function chrome(slide, { tag, dark = true }) {
105
+ if (tag) {
106
+ slide.addText(tag.toUpperCase(), {
107
+ x: 0.35, y: 0.24, w: 6.75, h: 0.17, margin: 0, isTextBox: true,
108
+ fontFace: F.reg, fontSize: 8, bold: true, color: T.accent, valign: "middle",
109
+ });
110
+ }
111
+ slide.addImage({
112
+ path: `${ASSETS}/${dark ? "mark-white.png" : "mark-dark.png"}`,
113
+ x: 9.52, y: 0.24, w: 0.23, h: 0.19,
114
+ });
115
+ }
116
+
117
+ /** Footer band. `n` and `total` are literal — never a field. */
118
+ function footer(slide, { deck, n, total, dark = true, confidential = "STRICTLY CONFIDENTIAL" }) {
119
+ const c = dark ? T.footerDark : T.footerLight;
120
+ slide.addText(`WAND · ${deck.toUpperCase()}`, {
121
+ x: 0.37, y: 5.21, w: 4.58, h: 0.42, margin: 0, isTextBox: true,
122
+ fontFace: F.reg, fontSize: 6, color: c, valign: "middle",
123
+ });
124
+ const pad = (v) => String(v).padStart(2, "0");
125
+ slide.addText(`${confidential} · ${pad(n)} / ${pad(total)}`, {
126
+ x: 5.23, y: 5.21, w: 4.76, h: 0.42, margin: 0, isTextBox: true,
127
+ fontFace: F.reg, fontSize: 6, color: c, align: "right", valign: "middle",
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Title with the accent-italic device. Pass either a plain string or
133
+ * ["Two kinds of colleagues. ", "One set of drives", "."] — the middle
134
+ * run gets accent + italic, the period stays in ink. One per title.
135
+ */
136
+ function title(slide, text, { dark = true, y = 0.62, h = 0.9, size = 24, w = 8.9 } = {}) {
137
+ const th = theme(dark);
138
+ const parts = Array.isArray(text) ? text : [text];
139
+ // A "\n" inside a run makes pptxgenjs start a new paragraph, which pushes every
140
+ // following run onto its own line. Split on it and carry the break explicitly.
141
+ const runs = [];
142
+ parts.forEach((part, i) => {
143
+ const style = i === 1 && parts.length > 1
144
+ ? { fontFace: F.reg, fontSize: size, bold: true, italic: true, color: T.accent }
145
+ : { fontFace: F.reg, fontSize: size, bold: true, color: th.ink };
146
+ String(part).split("\n").forEach((seg, j, arr) => {
147
+ runs.push({ text: seg, options: { ...style, breakLine: j !== arr.length - 1 } });
148
+ });
149
+ });
150
+ const flat = parts.join("");
151
+ const fitted = autoSize(flat, w, h, {
152
+ family: F.reg, bold: true, fontSize: size, min: 18, step: 0.5, lineSpacingMultiple: 1.08,
153
+ });
154
+ if (fitted !== size) runs.forEach((r) => { r.options.fontSize = fitted; });
155
+ slide.addText(runs, {
156
+ x: 0.37, y, w, h, margin: 0, isTextBox: true, valign: "top", lineSpacingMultiple: 1.08,
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Subtitle. Grows its own box to the height the text actually needs rather than
162
+ * clipping or shrinking below the §3 scale, and RETURNS ITS BOTTOM EDGE so the
163
+ * caller can place the content band under it.
164
+ *
165
+ * This is the v5 contentY() lesson: long titles collided with subtitles and long
166
+ * subtitles pushed into the content band, because both boxes were fixed height
167
+ * and the overflow was invisible. Measure, then place.
168
+ */
169
+ function subtitle(slide, text, { dark = true, y = 1.57, h = 0.42, w = 8.9 } = {}) {
170
+ const size = 12.5, lead = 1.2;
171
+ const lines = M.lineCount(text, w, { family: F.reg, fontSize: size });
172
+ // 3+ lines is a copy problem, not a layout problem — step down once, then wrap.
173
+ const fontSize = lines > 2 ? 11 : size;
174
+ const need = M.heightIn(text, w, { family: F.reg, fontSize, lineSpacingMultiple: lead });
175
+ const box = Math.max(h, need + 0.02);
176
+ slide.addText(text, {
177
+ x: 0.37, y, w, h: box, margin: 0, isTextBox: true,
178
+ fontFace: F.reg, fontSize, color: theme(dark).body,
179
+ valign: "top", lineSpacingMultiple: lead,
180
+ });
181
+ return y + box;
182
+ }
183
+
184
+ /**
185
+ * Where the content band starts, given the title and subtitle that precede it.
186
+ * Pass the same values you gave title()/subtitle(). Never hardcode a content y
187
+ * on a layout whose title can wrap.
188
+ */
189
+ function contentTop(titleText, subtitleText, { titleY = 0.62, titleSize = 24, w = 8.9, gap = 0.26 } = {}) {
190
+ const flat = Array.isArray(titleText) ? titleText.join("") : String(titleText || "");
191
+ let y = titleY + M.heightIn(flat, w, { family: F.reg, bold: true, fontSize: titleSize, lineSpacingMultiple: 1.08 });
192
+ if (subtitleText) {
193
+ const lines = M.lineCount(subtitleText, w, { family: F.reg, fontSize: 12.5 });
194
+ const size = lines > 2 ? 11 : 12.5;
195
+ y += 0.10 + M.heightIn(subtitleText, w, { family: F.reg, fontSize: size, lineSpacingMultiple: 1.2 });
196
+ }
197
+ return +(y + gap).toFixed(3);
198
+ }
199
+
200
+ /** Italic footnote under the content band. */
201
+ function kicker(slide, text, { dark = true, y = 4.82 } = {}) {
202
+ // A kicker is usually one short italic line, but a closing band can be a
203
+ // sentence. Measure it, and let it use the room down to the content limit.
204
+ const h = Math.min(0.30, 5.06 - y) + (String(text).length > 90 ? 0.16 : 0);
205
+ slide.addText(text, {
206
+ x: 0.37, y: Math.min(y, 5.06 - h), w: 9.25, h, margin: 0, isTextBox: true,
207
+ fontFace: F.reg,
208
+ fontSize: autoSize(text, 9.25, h, { family: F.reg, fontSize: 10, min: 7, lineSpacingMultiple: 1.2 }),
209
+ italic: true, color: theme(dark).body,
210
+ align: "center", valign: "middle", lineSpacingMultiple: 1.2,
211
+ });
212
+ }
213
+
214
+ /* ---------------------------------------------------------------- primitives */
215
+
216
+ function card(slide, { x, y, w, h, dark = true, variant = "glass" }) {
217
+ const th = theme(dark);
218
+ let fill = th.cardFill, line = th.cardLine;
219
+ if (variant === "solid") { fill = { color: T.cardSolid }; line = { color: T.cardMutedStroke, width: 1 }; }
220
+ if (variant === "muted") { fill = { color: T.cardMuted }; line = { color: T.cardMutedStroke, width: 1, dashType: "dash" }; }
221
+ if (variant === "emph") { fill = { color: T.cardEmphFlat }; line = { color: T.cardMutedStroke, width: 1 }; }
222
+ slide.addShape("roundRect", {
223
+ x, y, w, h, fill: { ...fill }, line: { ...line }, rectRadius: radius(w, h),
224
+ });
225
+ }
226
+
227
+ /** Icon tile + glyph. The glyph is a separate PNG — move them together. */
228
+ function iconTile(slide, { x, y, size = 0.46, icon, dark = true }) {
229
+ slide.addShape("roundRect", {
230
+ x, y, w: size, h: size,
231
+ fill: { color: theme(dark).tile }, line: { color: theme(dark).tile, width: 0 },
232
+ rectRadius: size * 0.30,
233
+ });
234
+ if (icon) {
235
+ const g = size * 0.42;
236
+ const box = { x: x + (size - g) / 2, y: y + (size - g) / 2, w: g, h: g };
237
+ // A named glyph is recoloured to the tile's own ink, so it can never be a
238
+ // white mark on a light tile. A filename is the legacy PNG set, taken as-is.
239
+ const uri = G.dataUri(icon, theme(dark).tileGlyphDark ? T.accentDeep : T.white);
240
+ if (uri) slide.addImage({ data: uri, ...box });
241
+ else slide.addImage({ path: `${ASSETS}/icons/${/\.\w+$/.test(icon) ? icon : icon + ".png"}`, ...box });
242
+ }
243
+ }
244
+
245
+ /**
246
+ * variant: "accent" filled accent, white text (L9 "ours" badge)
247
+ * "white" filled white, accent text (L4 emphasis-card pill)
248
+ * "outline" dark fill, accent hairline (L9 "bring your own", L4 default)
249
+ */
250
+ function pill(slide, { x, y, w, h = 0.34, text, variant = "outline", dark = true }) {
251
+ const fill = variant === "accent" ? { color: T.accent }
252
+ : variant === "white" ? { color: T.white }
253
+ : { color: dark ? "1B1738" : T.cardLight };
254
+ const line = variant === "accent" ? { color: T.accent, width: 1 }
255
+ : variant === "white" ? { color: T.white, width: 1 }
256
+ : { color: T.accentSoft, width: 1 };
257
+ const ink = variant === "accent" ? T.white
258
+ : variant === "white" ? T.accentDeep
259
+ : T.accentSoft;
260
+ slide.addShape("roundRect", { x, y, w, h, rectRadius: h / 2, fill, line });
261
+ slide.addText(text, {
262
+ x, y, w, h, margin: 0, isTextBox: true,
263
+ fontFace: F.med, fontSize: 9, color: ink, align: "center", valign: "middle",
264
+ });
265
+ }
266
+
267
+ /* ------------------------------------------------------------------ layouts */
268
+
269
+ /** L1 cover — dark only, full bleed, no footer. */
270
+ function cover(pres, { eyebrow, titleLines, body, kickerPlain, kickerBold, tag }) {
271
+ const s = pres.addSlide();
272
+ s.background = { color: T.bgDark };
273
+ s.addImage({ path: `${ASSETS}/bg-cover-dark.png`, x: 0, y: 0, w: 10, h: 5.625 });
274
+ s.addImage({ path: `${ASSETS}/orb-cover.png`, x: 5.72, y: 0.38, w: 4.61, h: 4.61 });
275
+ if (tag) chrome(s, { tag, dark: true });
276
+ s.addText(eyebrow.toUpperCase(), {
277
+ x: 0.38, y: 1.24, w: 6.0, h: 0.4, margin: 0, isTextBox: true,
278
+ fontFace: F.med, fontSize: 12, color: T.accentSoft, valign: "middle",
279
+ });
280
+ // 46pt is the §3 cover size, but a long product name can force a third line.
281
+ // Measure and step down rather than let it collide with the body.
282
+ const tSize = autoSize(titleLines.join("\n"), 5.55, 1.55,
283
+ { family: F.xbold, fontSize: 46, min: 32, lineSpacingMultiple: 1.05 });
284
+ s.addText(titleLines.join("\n"), {
285
+ x: 0.39, y: 1.58, w: 5.55, h: 1.55, margin: 0, isTextBox: true,
286
+ fontFace: F.xbold, fontSize: tSize, color: T.white, valign: "top", lineSpacingMultiple: 1.05,
287
+ });
288
+ s.addText(body, {
289
+ x: 0.39, y: 3.25, w: 5.52, h: 0.57, margin: 0, isTextBox: true,
290
+ fontFace: F.reg, fontSize: 12.5, color: T.inkDark, valign: "top", lineSpacingMultiple: 1.12,
291
+ });
292
+ s.addShape("rect", { x: 0.38, y: 3.95, w: 0.60, h: 0.04, fill: { color: T.accent }, line: { width: 0 } });
293
+ s.addText([
294
+ { text: kickerPlain + " ", options: { fontFace: F.reg, fontSize: 10, color: T.inkDark } },
295
+ { text: kickerBold, options: { fontFace: F.reg, fontSize: 10, bold: true, color: T.white } },
296
+ ], { x: 0.39, y: 4.03, w: 6.0, h: 0.4, margin: 0, isTextBox: true, valign: "middle" });
297
+ return s;
298
+ }
299
+
300
+ /** L2 divider — dark only, full bleed, tag kept, no footer. */
301
+ function divider(pres, { tag, eyebrow, titleText, body }) {
302
+ const s = pres.addSlide();
303
+ s.background = { color: T.bgDark };
304
+ s.addImage({ path: `${ASSETS}/bg-statement-dark.png`, x: 0, y: 0, w: 10, h: 5.625 });
305
+ chrome(s, { tag, dark: true });
306
+ s.addText(eyebrow.toUpperCase(), {
307
+ x: 0.38, y: 1.77, w: 8.9, h: 0.24, margin: 0, isTextBox: true,
308
+ fontFace: F.med, fontSize: 12, color: T.accentSoft, valign: "middle",
309
+ });
310
+ const runs = Array.isArray(titleText)
311
+ ? titleText.map((t, i) => ({
312
+ text: t,
313
+ options: i === 1
314
+ ? { fontFace: F.semi, fontSize: 44, italic: true, color: T.accentSoft }
315
+ : { fontFace: F.semi, fontSize: 44, color: T.white },
316
+ }))
317
+ : [{ text: titleText, options: { fontFace: F.semi, fontSize: 44, color: T.white } }];
318
+ s.addText(runs, { x: 0.38, y: 2.09, w: 8.6, h: 0.77, margin: 0, isTextBox: true, valign: "top" });
319
+ if (body) s.addText(body, {
320
+ x: 0.38, y: 2.91, w: 5.81, h: 0.72, margin: 0, isTextBox: true,
321
+ fontFace: F.reg, fontSize: 12, color: T.bodyDark, valign: "top", lineSpacingMultiple: 1.25,
322
+ });
323
+ return s;
324
+ }
325
+
326
+ /** L3 closing — dark, centred, full bleed. */
327
+ function closing(pres, { eyebrow, line1, line2, body, url }) {
328
+ const s = pres.addSlide();
329
+ s.background = { color: T.bgDark };
330
+ s.addImage({ path: `${ASSETS}/bg-statement-dark.png`, x: 0, y: 0, w: 10, h: 5.625 });
331
+ s.addImage({ path: `${ASSETS}/wordmark-white.png`, x: 4.60, y: 0.98, w: 0.80, h: 0.22 });
332
+ s.addText(eyebrow.toUpperCase(), {
333
+ x: 0.55, y: 1.85, w: 8.9, h: 0.3, margin: 0, isTextBox: true,
334
+ fontFace: F.med, fontSize: 12, color: T.accentSoft, align: "center", valign: "middle",
335
+ });
336
+ s.addText([
337
+ { text: line1 + "\n", options: { fontFace: F.reg, fontSize: 24, bold: true, color: T.white } },
338
+ { text: line2, options: { fontFace: F.reg, fontSize: 24, bold: true, italic: true, color: T.accentSoft } },
339
+ ], { x: 0.55, y: 2.19, w: 8.9, h: 1.0, margin: 0, isTextBox: true, align: "center", valign: "top", lineSpacingMultiple: 1.15 });
340
+ if (body) s.addText(body, {
341
+ x: 1.5, y: 3.23, w: 7.0, h: 0.7, margin: 0, isTextBox: true,
342
+ fontFace: F.reg, fontSize: 12.5, color: T.bodyDark, align: "center", valign: "top", lineSpacingMultiple: 1.25,
343
+ });
344
+ if (url) s.addText(url, {
345
+ x: 1.5, y: 4.30, w: 7.0, h: 0.3, margin: 0, isTextBox: true,
346
+ fontFace: F.reg, fontSize: 10, bold: true, color: T.accentSoft, align: "center", valign: "middle",
347
+ });
348
+ return s;
349
+ }
350
+
351
+ /**
352
+ * L4 / L5 threeUp — 3 tall cards. Pass `arrows: true` for the progression variant.
353
+ * items: [{ eyebrow, title, pill, body, emph }]
354
+ */
355
+ function threeUp(pres, { tag, titleText, subtitleText, items, dark = true, arrows = false, deck, n, total }) {
356
+ const s = pres.addSlide();
357
+ background(s, dark);
358
+ chrome(s, { tag, dark });
359
+ title(s, titleText, { dark, y: 0.76, h: 0.5 });
360
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.32, h: 0.5 });
361
+ const th = theme(dark);
362
+ const { w, x } = COLS["3tall"];
363
+ items.slice(0, 3).forEach((it, i) => {
364
+ const cx = x[i], cy = 2.02;
365
+ card(s, { x: cx, y: cy, w, h: 2.70, dark, variant: it.emph ? "emph" : "glass" });
366
+ s.addText(it.eyebrow.toUpperCase(), {
367
+ x: cx + 0.22, y: cy + 0.22, w: w - 0.42, h: 0.20, margin: 0, isTextBox: true,
368
+ fontFace: F.med, fontSize: 8, color: th.eyebrow, valign: "middle",
369
+ });
370
+ s.addText(it.title, {
371
+ x: cx + 0.22, y: cy + 0.46, w: w - 0.44, h: 0.34, margin: 0, isTextBox: true,
372
+ fontFace: F.semi, fontSize: 16, color: th.ink, valign: "middle",
373
+ });
374
+ if (it.pill) pill(s, { x: cx + 0.36, y: cy + 0.96, w: 2.00, text: it.pill, variant: it.emph ? "white" : "outline", dark });
375
+ s.addText(it.body, {
376
+ x: cx + 0.22, y: cy + 1.50, w: w - 0.42, h: 1.05, margin: 0, isTextBox: true,
377
+ fontFace: F.reg,
378
+ fontSize: autoSize(it.body, w - 0.42, 1.05, { family: F.reg, fontSize: 9, min: 6.5, lineSpacingMultiple: 1.25 }),
379
+ color: it.emph ? T.white : th.body, valign: "top", lineSpacingMultiple: 1.25,
380
+ });
381
+ });
382
+ if (arrows) [3.23, 6.50].forEach((ax) => {
383
+ s.addText("→", {
384
+ x: ax, y: 3.22, w: 0.27, h: 0.27, margin: 0, isTextBox: true,
385
+ fontFace: F.semi, fontSize: 16, color: T.accent, align: "center", valign: "middle",
386
+ });
387
+ });
388
+ if (deck) footer(s, { deck, n, total, dark });
389
+ return s;
390
+ }
391
+
392
+ /** L6 fourUpIcon — 4 icon cards. items: [{ icon, title, body }] */
393
+ function fourUpIcon(pres, { tag, titleText, subtitleText, items, kickerText, dark = true, deck, n, total }) {
394
+ const s = pres.addSlide();
395
+ background(s, dark);
396
+ chrome(s, { tag, dark });
397
+ title(s, titleText, { dark, y: 0.62, h: 0.9 });
398
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.57 });
399
+ const th = theme(dark);
400
+ const { w, x } = COLS[4];
401
+ items.slice(0, 4).forEach((it, i) => {
402
+ const cx = x[i], cy = 2.28;
403
+ card(s, { x: cx, y: cy, w, h: 2.34, dark });
404
+ iconTile(s, { x: cx + 0.22, y: cy + 0.24, size: 0.46, icon: it.icon, dark });
405
+ s.addText(it.title, {
406
+ x: cx + 0.22, y: cy + 0.86, w: w - 0.42, h: 0.28, margin: 0, isTextBox: true,
407
+ fontFace: F.med, fontSize: 12.5, color: th.ink, valign: "middle",
408
+ });
409
+ s.addText(it.body, {
410
+ x: cx + 0.21, y: cy + 1.20, w: w - 0.42, h: 1.02, margin: 0, isTextBox: true,
411
+ fontFace: F.reg, fontSize: 9, color: th.body, valign: "top", lineSpacingMultiple: 1.25,
412
+ });
413
+ });
414
+ if (kickerText) kicker(s, kickerText, { dark });
415
+ if (deck) footer(s, { deck, n, total, dark });
416
+ return s;
417
+ }
418
+
419
+ /** L7 sixUpIcon — 6 cards in two rows. items: [{ icon, title, body }] */
420
+ function sixUpIcon(pres, { tag, titleText, subtitleText, items, dark = true, deck, n, total }) {
421
+ const s = pres.addSlide();
422
+ background(s, dark);
423
+ chrome(s, { tag, dark });
424
+ title(s, titleText, { dark, y: 0.62, h: 0.6 });
425
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.18, h: 0.36 });
426
+ const th = theme(dark);
427
+ const { w, x } = COLS["3std"];
428
+ items.slice(0, 6).forEach((it, i) => {
429
+ const cx = x[i % 3], cy = i < 3 ? 1.63 : 3.46;
430
+ card(s, { x: cx, y: cy, w, h: 1.62, dark });
431
+ iconTile(s, { x: cx + 0.20, y: cy + 0.16, size: 0.40, icon: it.icon, dark });
432
+ s.addText(it.title, {
433
+ x: cx + 0.20, y: cy + 0.64, w: 2.44, h: 0.28, margin: 0, isTextBox: true,
434
+ fontFace: F.semi,
435
+ fontSize: autoSize(it.title, 2.44, 0.28, { family: F.semi, fontSize: 10.5, min: 7.5 }),
436
+ color: th.ink, valign: "middle",
437
+ });
438
+ s.addText(it.body, {
439
+ x: cx + 0.20, y: cy + 1.01, w: w - 0.40, h: it.note ? 0.42 : 0.55, margin: 0, isTextBox: true,
440
+ fontFace: F.reg,
441
+ fontSize: autoSize(it.body, w - 0.40, it.note ? 0.42 : 0.55, { family: F.reg, fontSize: 8, min: 6, lineSpacingMultiple: 1.2 }),
442
+ color: th.body, valign: "top", lineSpacingMultiple: 1.2,
443
+ });
444
+ if (it.note) s.addText(it.note, {
445
+ x: cx + 0.20, y: cy + 1.30, w: w - 0.40, h: 0.22, margin: 0, isTextBox: true,
446
+ fontFace: F.semi,
447
+ fontSize: autoSize(it.note, w - 0.40, 0.22, { family: F.semi, fontSize: 8, min: 6.5 }),
448
+ color: T.accent, valign: "middle",
449
+ });
450
+ });
451
+ if (deck) footer(s, { deck, n, total, dark });
452
+ return s;
453
+ }
454
+
455
+ /**
456
+ * L9 rows — 5 full-width rows. Best layout for a stack / ownership table.
457
+ * items: [{ icon, label, description, badge, ours }]
458
+ */
459
+ function rows(pres, { tag, titleText, subtitleText, items, dark = true, deck, n, total }) {
460
+ const s = pres.addSlide();
461
+ background(s, dark);
462
+ chrome(s, { tag, dark });
463
+ title(s, titleText, { dark, y: 0.62, h: 0.9 });
464
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.50, h: 0.36 });
465
+ const th = theme(dark);
466
+ items.slice(0, 5).forEach((it, i) => {
467
+ const y = 1.94 + i * 0.645;
468
+ card(s, { x: 0.37, y, w: 9.25, h: 0.58, dark, variant: it.ours === false ? "muted" : "glass" });
469
+ iconTile(s, { x: 0.57, y: y + 0.11, size: 0.36, icon: it.icon, dark });
470
+ s.addText(it.label, {
471
+ x: 1.08, y, w: 1.95, h: 0.58, margin: 0, isTextBox: true,
472
+ fontFace: F.med, fontSize: 10.5, color: th.ink, valign: "middle",
473
+ });
474
+ s.addText(it.description, {
475
+ x: 3.03, y, w: 4.76, h: 0.58, margin: 0, isTextBox: true,
476
+ fontFace: F.reg, fontSize: 8, color: th.body, valign: "middle", lineSpacingMultiple: 1.2,
477
+ });
478
+ if (it.badge) pill(s, { x: 8.15, y: y + 0.14, w: 1.30, h: 0.30, text: it.badge, variant: it.ours === false ? "outline" : "accent", dark });
479
+ });
480
+ if (deck) footer(s, { deck, n, total, dark });
481
+ return s;
482
+ }
483
+
484
+ /**
485
+ * L10 parity — two contrasting cards with check rows and a centre badge.
486
+ * left/right: { label, icon, items: [] }
487
+ */
488
+ function parity(pres, { tag, titleText, subtitleText, left, right, badge = "=", badgeNote, kickerText, dark = false, deck, n, total }) {
489
+ const s = pres.addSlide();
490
+ background(s, dark);
491
+ chrome(s, { tag, dark });
492
+ title(s, titleText, { dark, y: 0.62, h: 0.5 });
493
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.14, h: 0.36 });
494
+ const sides = [
495
+ { d: left, x: 0.37, emph: false },
496
+ { d: right, x: 5.38, emph: true },
497
+ ];
498
+ sides.forEach(({ d, x, emph }) => {
499
+ const y = 1.68;
500
+ card(s, { x, y, w: 4.25, h: 2.92, dark: emph ? true : dark, variant: emph ? "emph" : "glass" });
501
+ const ink = emph ? T.white : theme(dark).ink;
502
+ if (d.eyebrow) s.addText(d.eyebrow.toUpperCase(), {
503
+ x: x + 0.30, y: y + 0.16, w: 3.65, h: 0.18, margin: 0, isTextBox: true,
504
+ fontFace: F.med, fontSize: 8, color: emph ? T.accentPale : theme(dark).eyebrow, valign: "middle",
505
+ });
506
+ if (d.icon) iconTile(s, { x: x + 0.30, y: y + 0.26, size: 0.44, icon: d.icon, dark: emph ? true : dark });
507
+ s.addText(d.label.toUpperCase(), {
508
+ x: x + 0.89, y: y + 0.26, w: 3.20, h: 0.44, margin: 0, isTextBox: true,
509
+ fontFace: F.semi, fontSize: 12, color: emph ? T.accentSoft : T.accentDeep, valign: "middle",
510
+ });
511
+ const rows = d.items.slice(0, 5);
512
+ const rowTop = y + 0.90, rowH = (2.92 - 1.02) / rows.length;
513
+ rows.forEach((t, i) => {
514
+ const ry = rowTop + i * rowH;
515
+ s.addText("✓", {
516
+ x: x + 0.35, y: ry, w: 0.20, h: 0.22, margin: 0, isTextBox: true,
517
+ fontFace: F.semi, fontSize: 11, color: T.accent, valign: "middle",
518
+ });
519
+ s.addText(t, {
520
+ x: x + 0.61, y: ry, w: 3.50, h: rowH, margin: 0, isTextBox: true,
521
+ fontFace: F.reg,
522
+ fontSize: autoSize(t, 3.50, rowH, { family: F.reg, fontSize: 9.5, min: 5.5, lineSpacingMultiple: 1.2 }),
523
+ color: ink, valign: "middle", lineSpacingMultiple: 1.2,
524
+ });
525
+ });
526
+ });
527
+ s.addShape("ellipse", { x: 4.75, y: 2.89, w: 0.50, h: 0.50, fill: { color: T.accent }, line: { width: 0 } });
528
+ s.addText(badge, {
529
+ x: 4.75, y: 2.89, w: 0.50, h: 0.50, margin: 0, isTextBox: true,
530
+ fontFace: F.semi, fontSize: 17, color: T.white, align: "center", valign: "middle",
531
+ });
532
+ if (badgeNote) s.addText(badgeNote.toUpperCase(), {
533
+ x: 4.35, y: 3.42, w: 1.30, h: 0.18, margin: 0, isTextBox: true,
534
+ fontFace: F.med,
535
+ fontSize: autoSize(badgeNote.toUpperCase(), 1.30, 0.18, { family: F.med, fontSize: 7, min: 5.5 }),
536
+ color: T.accent, align: "center", valign: "middle",
537
+ });
538
+ if (kickerText) kicker(s, kickerText, { dark, y: 4.84 });
539
+ if (deck) footer(s, { deck, n, total, dark });
540
+ return s;
541
+ }
542
+
543
+ /** L15 statQuad — 4 stat cards. items: [{ value, caption }] */
544
+ function statQuad(pres, { tag, titleText, subtitleText, items, kickerText, dark = false, deck, n, total }) {
545
+ const s = pres.addSlide();
546
+ background(s, dark);
547
+ chrome(s, { tag, dark });
548
+ title(s, titleText, { dark, y: 0.62, h: 0.9 });
549
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.57 });
550
+ const th = theme(dark);
551
+ const { w, x } = COLS["4stat"];
552
+ items.slice(0, 4).forEach((it, i) => {
553
+ const cx = x[i], cy = 2.90;
554
+ card(s, { x: cx, y: cy, w, h: 1.42, dark });
555
+ s.addText(it.value, {
556
+ x: cx + 0.18, y: cy + 0.16, w: w - 0.36, h: 0.42, margin: 0, isTextBox: true,
557
+ fontFace: F.semi, fontSize: 21, color: T.accent, valign: "middle",
558
+ });
559
+ s.addText(it.caption, {
560
+ x: cx + 0.18, y: cy + 0.66, w: w - 0.36, h: 0.62, margin: 0, isTextBox: true,
561
+ fontFace: F.reg, fontSize: 8.5, color: th.body, valign: "top", lineSpacingMultiple: 1.2,
562
+ });
563
+ });
564
+ if (kickerText) kicker(s, kickerText, { dark, y: 4.70 });
565
+ if (deck) footer(s, { deck, n, total, dark });
566
+ return s;
567
+ }
568
+
569
+ /** L16 statGrid — 6 cards, 3x2. items: [{ value, label, caption, emph }] */
570
+ function statGrid(pres, { tag, titleText, subtitleText, items, footnote, dark = false, deck, n, total }) {
571
+ const s = pres.addSlide();
572
+ background(s, dark);
573
+ chrome(s, { tag, dark });
574
+ title(s, titleText, { dark, y: 0.62, h: 0.6 });
575
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.18, h: 0.36 });
576
+ const th = theme(dark);
577
+ items.slice(0, 6).forEach((it, i) => {
578
+ const cx = 0.37 + (i % 3) * 3.25, cy = 1.60 + Math.floor(i / 3) * 1.60;
579
+ card(s, { x: cx, y: cy, w: 3.00, h: 1.35, dark, variant: it.emph ? "emph" : "glass" });
580
+ s.addText(it.value, {
581
+ x: cx + 0.20, y: cy + 0.14, w: 2.60, h: 0.40, margin: 0, isTextBox: true,
582
+ fontFace: F.semi, fontSize: 20, color: it.emph ? T.white : T.accent, valign: "middle",
583
+ });
584
+ s.addText(it.label, {
585
+ x: cx + 0.20, y: cy + 0.58, w: 2.60, h: 0.28, margin: 0, isTextBox: true,
586
+ fontFace: F.semi, fontSize: 12, color: it.emph ? T.white : th.ink, valign: "middle",
587
+ });
588
+ if (it.caption) s.addText(it.caption, {
589
+ x: cx + 0.20, y: cy + 0.88, w: 2.60, h: 0.36, margin: 0, isTextBox: true,
590
+ fontFace: F.reg, fontSize: 8, color: it.emph ? T.accentPale : T.accentDeep, valign: "top",
591
+ });
592
+ });
593
+ if (footnote) kicker(s, footnote, { dark, y: 4.70 });
594
+ if (deck) footer(s, { deck, n, total, dark });
595
+ return s;
596
+ }
597
+
598
+ /** L20 band — full-width assertion or equation band, usually paired with statQuad. */
599
+ function band(pres, { tag, titleText, subtitleText, icon, runs, stats, quote, dark = false, deck, n, total }) {
600
+ const s = pres.addSlide();
601
+ background(s, dark);
602
+ chrome(s, { tag, dark });
603
+ title(s, titleText, { dark, y: 0.62, h: 0.5 });
604
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.14, h: 0.5 });
605
+ s.addShape("roundRect", {
606
+ x: 0.37, y: 1.78, w: 9.25, h: 0.86,
607
+ fill: { color: T.band }, line: { color: T.band, width: 0 }, rectRadius: radius(9.25, 0.86),
608
+ });
609
+ iconTile(s, { x: 0.68, y: 2.00, size: 0.42, icon, dark: true });
610
+ s.addText(
611
+ runs.map((r) => ({
612
+ text: r.text,
613
+ options: {
614
+ fontFace: F.semi, fontSize: 13.5,
615
+ color: r.accent ? T.accentSoft : T.white,
616
+ italic: !!r.accent,
617
+ },
618
+ })),
619
+ { x: 1.30, y: 1.78, w: 7.75, h: 0.86, margin: 0, isTextBox: true, valign: "middle" }
620
+ );
621
+ if (stats) {
622
+ const { w, x } = COLS["4stat"];
623
+ stats.slice(0, 4).forEach((it, i) => {
624
+ const cx = x[i], cy = 2.90;
625
+ card(s, { x: cx, y: cy, w, h: 1.42, dark });
626
+ s.addText(it.value, {
627
+ x: cx + 0.18, y: cy + 0.16, w: w - 0.36, h: 0.42, margin: 0, isTextBox: true,
628
+ fontFace: F.semi, fontSize: 21, color: T.accent, valign: "middle",
629
+ });
630
+ s.addText(it.caption, {
631
+ x: cx + 0.18, y: cy + 0.66, w: w - 0.36, h: 0.62, margin: 0, isTextBox: true,
632
+ fontFace: F.reg, fontSize: 8.5, color: theme(dark).body, valign: "top", lineSpacingMultiple: 1.2,
633
+ });
634
+ });
635
+ }
636
+ if (quote) kicker(s, quote, { dark, y: 4.70 });
637
+ if (deck) footer(s, { deck, n, total, dark });
638
+ return s;
639
+ }
640
+
641
+
642
+ /**
643
+ * L11 colCards — 2 or 3 tall cards carrying an image, prose, and a bullet list.
644
+ * Measured from reference S07: 2.90 x 3.51 at y1.44.
645
+ * items: [{ art, eyebrow, title, body, bullets: [] }]
646
+ */
647
+ function colCards(pres, { tag, titleText, subtitleText, items, dark = true, deck, n, total }) {
648
+ const s = pres.addSlide();
649
+ background(s, dark);
650
+ chrome(s, { tag, dark });
651
+ title(s, titleText, { dark, y: 0.73, h: 0.46, w: 9.49 });
652
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.20, h: 0.30 });
653
+ const th = theme(dark);
654
+ const three = items.length >= 3;
655
+ const w = three ? 2.90 : 4.30;
656
+ const xs = three ? COLS["3wide"].x : COLS[2].x;
657
+ items.slice(0, 3).forEach((it, i) => {
658
+ const cx = xs[i], cy = 1.44, h = 3.51;
659
+ card(s, { x: cx, y: cy, w, h, dark });
660
+ if (it.art) s.addImage({ path: `${ASSETS}/${it.art}`, x: cx + 0.82, y: cy + 0.02, w: 1.20, h: 1.20 });
661
+ s.addText(it.eyebrow, {
662
+ x: cx + 0.19, y: cy + 1.10, w: 2.47, h: 0.23, margin: 0, isTextBox: true,
663
+ fontFace: F.semi, fontSize: 9, color: th.eyebrow, align: "center", valign: "middle",
664
+ });
665
+ s.addText(it.title, {
666
+ x: cx + 0.19, y: cy + 1.22, w: 2.47, h: 0.38, margin: 0, isTextBox: true,
667
+ fontFace: F.semi, fontSize: 15, color: th.ink, align: "center", valign: "middle",
668
+ });
669
+ if (it.sub) s.addText(it.sub, {
670
+ x: cx + 0.19, y: cy + 1.60, w: 2.47, h: 0.22, margin: 0, isTextBox: true,
671
+ fontFace: F.med, fontSize: 9, italic: true, color: th.eyebrow,
672
+ align: "center", valign: "middle",
673
+ });
674
+ if (it.body) s.addText(it.body, {
675
+ x: cx + 0.19, y: cy + (it.sub ? 1.86 : 1.72), w: 2.47, h: it.sub ? 0.72 : 0.79, margin: 0, isTextBox: true,
676
+ fontFace: F.reg,
677
+ fontSize: autoSize(it.body, 2.47, it.sub ? 0.72 : 0.79, { family: F.reg, fontSize: 8, min: 6, lineSpacingMultiple: 1.25 }),
678
+ color: th.body, valign: "top", lineSpacingMultiple: 1.25,
679
+ });
680
+ if (it.bullets && it.bullets.length) {
681
+ s.addText(
682
+ it.bullets.map((b, j) => ({
683
+ text: b,
684
+ options: { bullet: { code: "25A0" }, breakLine: j !== it.bullets.length - 1 },
685
+ })),
686
+ { x: cx + 0.20, y: cy + (it.body ? 2.66 : 1.86), w: 2.55, h: it.body ? 0.80 : 1.60, margin: 0, isTextBox: true,
687
+ fontFace: F.reg,
688
+ fontSize: autoSize(it.bullets.join(" "), 2.35, it.body ? 0.80 : 1.60,
689
+ { family: F.reg, fontSize: 8, min: 6, lineSpacingMultiple: 1.25 }),
690
+ color: th.alt, valign: "top", paraSpaceAfter: 3 }
691
+ );
692
+ }
693
+ });
694
+ if (deck) footer(s, { deck, n, total, dark });
695
+ return s;
696
+ }
697
+
698
+ /**
699
+ * L17 featureStats — one emphasis card carrying a sentence, then 3 stat cards.
700
+ * Measured from reference S04: 2.09 x 2.10 at y2.63, x 0.37 / 2.75 / 5.13 / 7.51.
701
+ * lead: { icon, runs: [{text, accent}] } stats: [{ value, caption }]
702
+ */
703
+ function featureStats(pres, { tag, titleText, subtitleText, lead, stats, dark = false, deck, n, total }) {
704
+ const s = pres.addSlide();
705
+ background(s, dark);
706
+ chrome(s, { tag, dark });
707
+ title(s, titleText, { dark, y: 0.76, h: 0.92 });
708
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.69, h: 0.56 });
709
+ const th = theme(dark);
710
+ const xs = [0.37, 2.75, 5.13, 7.51];
711
+ card(s, { x: xs[0], y: 2.63, w: 2.09, h: 2.10, dark: true, variant: "emph" });
712
+ iconTile(s, { x: xs[0] + 0.17, y: 2.88, size: 0.42, icon: lead.icon, dark: true });
713
+ s.addText(
714
+ lead.runs.map((r) => ({
715
+ text: r.text,
716
+ options: { fontFace: F.reg, fontSize: 11, italic: !!r.accent,
717
+ color: r.accent ? T.accentSoft : T.white },
718
+ })),
719
+ { x: xs[0] + 0.17, y: 3.52, w: 1.75, h: 1.05, margin: 0, isTextBox: true,
720
+ valign: "top", lineSpacingMultiple: 1.2 }
721
+ );
722
+ stats.slice(0, 3).forEach((it, i) => {
723
+ const cx = xs[i + 1];
724
+ card(s, { x: cx, y: 2.63, w: 2.09, h: 2.10, dark });
725
+ s.addText(it.value, {
726
+ x: cx + 0.18, y: 2.90, w: 1.75, h: 0.50, margin: 0, isTextBox: true,
727
+ fontFace: F.semi, fontSize: 26, color: T.accent, valign: "middle",
728
+ });
729
+ s.addText(it.caption, {
730
+ x: cx + 0.18, y: 3.62, w: 1.78, h: 0.80, margin: 0, isTextBox: true,
731
+ fontFace: F.reg, fontSize: 11, color: th.body, valign: "top", lineSpacingMultiple: 1.2,
732
+ });
733
+ });
734
+ if (deck) footer(s, { deck, n, total, dark });
735
+ return s;
736
+ }
737
+
738
+ /**
739
+ * L18 barRank — ranked magnitudes as shapes, never addChart(). Spec §5 / §9.
740
+ * Measured from reference S12: track 5.40 x 0.48 at x0.37 y2.10, step 0.64.
741
+ * bars: [{ label, value, frac }] cards: [{ icon, value, caption }]
742
+ */
743
+ function barRank(pres, { tag, titleText, subtitleText, bars, cards, footnote, dark = false, deck, n, total }) {
744
+ const s = pres.addSlide();
745
+ background(s, dark);
746
+ chrome(s, { tag, dark });
747
+ title(s, titleText, { dark, y: 0.62, h: 0.9 });
748
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.56, h: 0.30 });
749
+ const th = theme(dark);
750
+ const RAMP = ["7C6AE8", "8B7CF6", "9C8EF6", "6F5DE8"];
751
+ bars.slice(0, 4).forEach((b, i) => {
752
+ const y = 2.10 + i * 0.64, TRACK = 5.40;
753
+ s.addShape("roundRect", {
754
+ x: 0.37, y, w: TRACK, h: 0.48, rectRadius: radius(TRACK, 0.48),
755
+ fill: { color: dark ? T.cardMuted : T.track }, line: { width: 0 },
756
+ });
757
+ const fw = Math.max(0.30, Math.min(1, b.frac) * TRACK);
758
+ s.addShape("roundRect", {
759
+ x: 0.37, y, w: fw, h: 0.48, rectRadius: radius(fw, 0.48),
760
+ fill: { color: RAMP[i % RAMP.length] }, line: { width: 0 },
761
+ });
762
+ s.addText(b.label, {
763
+ x: 0.55, y, w: Math.max(0.60, fw - 0.26), h: 0.48, margin: 0, isTextBox: true,
764
+ fontFace: F.semi, fontSize: autoSize(b.label, Math.max(0.60, fw - 0.26), 0.44, { family: F.semi, fontSize: 9.5, min: 7 }), color: T.white, valign: "middle",
765
+ });
766
+ const inside = fw > TRACK - 1.1;
767
+ s.addText(b.value, {
768
+ x: 0.55, y, w: TRACK - 0.36, h: 0.48, margin: 0, isTextBox: true,
769
+ fontFace: F.semi, fontSize: 10.5, color: inside ? T.white : th.ink,
770
+ align: "right", valign: "middle",
771
+ });
772
+ });
773
+ (cards || []).slice(0, 3).forEach((c, i) => {
774
+ const y = 2.10 + i * 1.00;
775
+ card(s, { x: 6.38, y, w: 3.25, h: 0.92, dark });
776
+ iconTile(s, { x: 6.58, y: y + 0.26, size: 0.40, icon: c.icon, dark });
777
+ s.addText(c.value, {
778
+ x: 7.13, y: y + 0.12, w: 2.35, h: 0.30, margin: 0, isTextBox: true,
779
+ fontFace: F.semi, fontSize: 15, color: T.accent, valign: "middle",
780
+ });
781
+ s.addText(c.caption, {
782
+ x: 7.13, y: y + 0.44, w: 2.35, h: 0.36, margin: 0, isTextBox: true,
783
+ fontFace: F.reg, fontSize: 8, color: th.body, valign: "top", lineSpacingMultiple: 1.15,
784
+ });
785
+ });
786
+ if (footnote) kicker(s, footnote, { dark, y: 4.76 });
787
+ if (deck) footer(s, { deck, n, total, dark });
788
+ return s;
789
+ }
790
+
791
+ /**
792
+ * L19 bridge — a labelled waterfall in one card, plus a 2x2 stat block.
793
+ * Measured from reference S15: card 5.38 x 3.16 at x0.38 y1.74.
794
+ * rows: [{ label, value, frac }] stats: [{ value, label, caption }]
795
+ */
796
+ function bridge(pres, { tag, titleText, subtitleText, eyebrow, rows: brows, stats, dark = false, deck, n, total }) {
797
+ const s = pres.addSlide();
798
+ background(s, dark);
799
+ chrome(s, { tag, dark });
800
+ title(s, titleText, { dark, y: 0.62, h: 0.44 });
801
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.12, h: 0.42 });
802
+ const th = theme(dark);
803
+ card(s, { x: 0.38, y: 1.74, w: 5.38, h: 3.16, dark });
804
+ if (eyebrow) s.addText(eyebrow.toUpperCase(), {
805
+ x: 0.67, y: 1.96, w: 3.0, h: 0.22, margin: 0, isTextBox: true,
806
+ fontFace: F.semi, fontSize: 8, color: T.accentDeep, valign: "middle",
807
+ });
808
+ brows.slice(0, 5).forEach((r, i) => {
809
+ const y = 2.34 + i * 0.495, TRACK = 1.75;
810
+ s.addText(r.label, {
811
+ x: 0.67, y, w: 1.92, h: 0.34, margin: 0, isTextBox: true,
812
+ fontFace: F.reg, fontSize: 9.5, color: th.ink, valign: "middle",
813
+ });
814
+ s.addShape("roundRect", {
815
+ x: 2.64, y: y + 0.06, w: TRACK, h: 0.22, rectRadius: radius(TRACK, 0.22),
816
+ fill: { color: dark ? T.cardMuted : T.track }, line: { width: 0 },
817
+ });
818
+ const fw = Math.max(0.06, Math.min(1, r.frac) * TRACK);
819
+ s.addShape("roundRect", {
820
+ x: 2.64, y: y + 0.06, w: fw, h: 0.22, rectRadius: radius(fw, 0.22),
821
+ fill: { color: T.accent }, line: { width: 0 },
822
+ });
823
+ s.addText(r.value, {
824
+ x: 4.44, y, w: 0.95, h: 0.34, margin: 0, isTextBox: true,
825
+ fontFace: F.semi, fontSize: 9.5, color: th.ink, align: "right", valign: "middle",
826
+ });
827
+ });
828
+ (stats || []).slice(0, 4).forEach((c, i) => {
829
+ const cx = i % 2 ? 7.95 : 6.01, cy = i < 2 ? 1.74 : 3.41;
830
+ card(s, { x: cx, y: cy, w: 1.68, h: 1.50, dark });
831
+ s.addText(c.value, {
832
+ x: cx + 0.14, y: cy + 0.13, w: 1.44, h: 0.32, margin: 0, isTextBox: true,
833
+ fontFace: F.semi, fontSize: autoSize(c.value, 1.44, 0.32, { family: F.semi, fontSize: 17, min: 11 }), color: T.accent, valign: "middle",
834
+ });
835
+ s.addText(c.label.toUpperCase(), {
836
+ x: cx + 0.14, y: cy + 0.50, w: 1.44, h: 0.34, margin: 0, isTextBox: true,
837
+ fontFace: F.semi, fontSize: 8, color: th.ink, valign: "top",
838
+ });
839
+ if (c.caption) s.addText(c.caption, {
840
+ x: cx + 0.14, y: cy + 0.88, w: 1.42, h: 0.55, margin: 0, isTextBox: true,
841
+ fontFace: F.reg, fontSize: 8, color: th.body, valign: "top", lineSpacingMultiple: 1.15,
842
+ });
843
+ });
844
+ if (deck) footer(s, { deck, n, total, dark });
845
+ return s;
846
+ }
847
+
848
+ /**
849
+ * heroQuad — one tall hero card carrying two stacked figures, plus a 2x2 icon grid.
850
+ * Measured from reference S14: hero 4.21 x 3.06 at x0.38 y1.71; tiles 2.27 x 1.43.
851
+ * hero: { pill, bigValue, bigCaption, subValue, subCaption }
852
+ * tiles: [{ icon, title, body }]
853
+ */
854
+ function heroQuad(pres, { tag, titleText, subtitleText, hero, tiles, kickerText, dark = true, deck, n, total }) {
855
+ const s = pres.addSlide();
856
+ background(s, dark);
857
+ chrome(s, { tag, dark });
858
+ title(s, titleText, { dark, y: 0.62, h: 0.5 });
859
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.11, h: 0.46 });
860
+ const th = theme(dark);
861
+ card(s, { x: 0.38, y: 1.71, w: 4.21, h: 3.06, dark });
862
+ if (hero.pill) pill(s, { x: 0.67, y: 1.95, w: 2.62, h: 0.34, text: hero.pill, variant: "outline", dark });
863
+ s.addText(hero.bigValue, {
864
+ x: 0.67, y: 2.39, w: 3.4, h: 0.56, margin: 0, isTextBox: true,
865
+ fontFace: F.semi, fontSize: 34, color: T.accent, valign: "middle",
866
+ });
867
+ s.addText(hero.bigCaption, {
868
+ x: 0.67, y: 2.97, w: 3.4, h: 0.24, margin: 0, isTextBox: true,
869
+ fontFace: F.reg, fontSize: 9, color: th.body, valign: "middle",
870
+ });
871
+ s.addShape("rect", { x: 0.67, y: 3.36, w: 3.30, h: 0.02,
872
+ fill: { color: dark ? T.cardMutedStroke : T.divider }, line: { width: 0 } });
873
+ s.addText(hero.subValue, {
874
+ x: 0.67, y: 3.49, w: 3.4, h: 0.42, margin: 0, isTextBox: true,
875
+ fontFace: F.semi, fontSize: 26, color: th.ink, valign: "middle",
876
+ });
877
+ s.addText(hero.subCaption, {
878
+ x: 0.67, y: 3.95, w: 3.3, h: 0.40, margin: 0, isTextBox: true,
879
+ fontFace: F.reg, fontSize: 9, color: th.body, valign: "top", lineSpacingMultiple: 1.2,
880
+ });
881
+ tiles.slice(0, 4).forEach((t, i) => {
882
+ const cx = i % 2 ? 7.36 : 4.85, cy = i < 2 ? 1.71 : 3.34;
883
+ card(s, { x: cx, y: cy, w: 2.27, h: 1.43, dark });
884
+ iconTile(s, { x: cx + 0.18, y: cy + 0.18, size: 0.40, icon: t.icon, dark });
885
+ s.addText(t.title, {
886
+ x: cx + 0.68, y: cy + 0.18, w: 1.45, h: 0.40, margin: 0, isTextBox: true,
887
+ fontFace: F.semi, fontSize: 11, color: th.ink, valign: "middle",
888
+ });
889
+ s.addText(t.body, {
890
+ x: cx + 0.18, y: cy + 0.72, w: 1.91, h: 0.60, margin: 0, isTextBox: true,
891
+ fontFace: F.reg, fontSize: 8, color: th.body, valign: "top", lineSpacingMultiple: 1.2,
892
+ });
893
+ });
894
+ if (badgeNote) s.addText(badgeNote.toUpperCase(), {
895
+ x: 4.35, y: 3.42, w: 1.30, h: 0.18, margin: 0, isTextBox: true,
896
+ fontFace: F.med,
897
+ fontSize: autoSize(badgeNote.toUpperCase(), 1.30, 0.18, { family: F.med, fontSize: 7, min: 5.5 }),
898
+ color: T.accent, align: "center", valign: "middle",
899
+ });
900
+ if (kickerText) kicker(s, kickerText, { dark, y: 4.84 });
901
+ if (deck) footer(s, { deck, n, total, dark });
902
+ return s;
903
+ }
904
+
905
+ /**
906
+ * productMap — rail-labelled product architecture: one full band, three cards, a
907
+ * split workforce row. Measured from reference S11.
908
+ * bandRow: { rail, icon, title, body }
909
+ * cards: [{ icon, title, body, bullets: [] }]
910
+ * split: { rail, left, right } base: string
911
+ */
912
+ function productMap(pres, { tag, titleText, subtitleText, bandRow, cards, split, base, dark = false, deck, n, total }) {
913
+ const s = pres.addSlide();
914
+ background(s, dark);
915
+ chrome(s, { tag, dark });
916
+ title(s, titleText, { dark, y: 0.62, h: 0.48 });
917
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.16, h: 0.28 });
918
+ const th = theme(dark);
919
+ const rail = (label, y, h) => {
920
+ s.addShape("roundRect", {
921
+ x: 0.37, y, w: 1.0, h, rectRadius: radius(1.0, h),
922
+ fill: { color: dark ? T.cardMuted : T.surface }, line: { width: 0 },
923
+ });
924
+ s.addText(label.toUpperCase(), {
925
+ x: 0.37, y, w: 1.0, h, margin: 0, isTextBox: true,
926
+ fontFace: F.med, fontSize: 8.5, color: T.accentDeep, align: "center", valign: "middle",
927
+ });
928
+ };
929
+
930
+ rail(bandRow.rail, 1.60, 0.62);
931
+ s.addShape("roundRect", {
932
+ x: 1.52, y: 1.60, w: 7.92, h: 0.62, rectRadius: radius(7.92, 0.62),
933
+ fill: { color: T.cardEmphFlat }, line: { color: T.cardMutedStroke, width: 1 },
934
+ });
935
+ iconTile(s, { x: 1.70, y: 1.72, size: 0.38, icon: bandRow.icon, dark: true });
936
+ s.addText(bandRow.title, {
937
+ x: 2.22, y: 1.68, w: 2.4, h: 0.24, margin: 0, isTextBox: true,
938
+ fontFace: F.semi, fontSize: 12, color: T.white, valign: "middle",
939
+ });
940
+ s.addText(bandRow.body, {
941
+ x: 2.22, y: 1.93, w: 6.8, h: 0.22, margin: 0, isTextBox: true,
942
+ fontFace: F.reg, fontSize: 8, color: T.bodyDark, valign: "middle",
943
+ });
944
+
945
+ rail("EXECUTE", 2.36, 1.88);
946
+ const xs = [1.52, 4.16, 6.97];
947
+ cards.slice(0, 3).forEach((c, i) => {
948
+ const cx = xs[i];
949
+ card(s, { x: cx, y: 2.36, w: 2.48, h: 1.88, dark });
950
+ iconTile(s, { x: cx + 0.18, y: 2.52, size: 0.38, icon: c.icon, dark });
951
+ s.addText(c.title, {
952
+ x: cx + 0.66, y: 2.52, w: 1.68, h: 0.38, margin: 0, isTextBox: true,
953
+ fontFace: F.semi, fontSize: 11.5, color: th.ink, valign: "middle",
954
+ });
955
+ s.addText(c.body, {
956
+ x: cx + 0.18, y: 3.02, w: 2.14, h: 0.48, margin: 0, isTextBox: true,
957
+ fontFace: F.reg, fontSize: 8, color: th.body, valign: "top", lineSpacingMultiple: 1.2,
958
+ });
959
+ if (c.bullets) s.addText(
960
+ c.bullets.map((b, j) => ({ text: b, options: { bullet: true, breakLine: j !== c.bullets.length - 1 } })),
961
+ { x: cx + 0.22, y: 3.52, w: 2.12, h: 0.70, margin: 0, isTextBox: true,
962
+ fontFace: F.reg, fontSize: 8, color: T.accentDeep, valign: "top", paraSpaceAfter: 2 }
963
+ );
964
+ });
965
+
966
+ rail(split.rail || "WORKFORCE", 4.38, 0.62);
967
+ s.addShape("roundRect", {
968
+ x: 1.52, y: 4.38, w: 3.84, h: 0.62, rectRadius: radius(3.84, 0.62),
969
+ fill: { color: T.cardEmphFlat }, line: { color: T.cardMutedStroke, width: 1 },
970
+ });
971
+ s.addText(split.left, {
972
+ x: 1.75, y: 4.38, w: 3.5, h: 0.62, margin: 0, isTextBox: true,
973
+ fontFace: F.semi, fontSize: 12, color: T.white, valign: "middle",
974
+ });
975
+ s.addShape("roundRect", {
976
+ x: 5.60, y: 4.38, w: 3.84, h: 0.62, rectRadius: radius(3.84, 0.62),
977
+ fill: { color: dark ? T.cardMuted : T.cardLight },
978
+ line: { color: dark ? T.cardMutedStroke : T.divider, width: 1 },
979
+ });
980
+ s.addText(split.right, {
981
+ x: 5.83, y: 4.38, w: 3.5, h: 0.62, margin: 0, isTextBox: true,
982
+ fontFace: F.semi, fontSize: 12, color: th.ink, valign: "middle",
983
+ });
984
+ if (base) s.addText(base.toUpperCase(), {
985
+ x: 0.37, y: 5.02, w: 9.25, h: 0.22, margin: 0, isTextBox: true,
986
+ fontFace: F.med, fontSize: 8.5, italic: true, color: th.body,
987
+ align: "center", valign: "middle",
988
+ });
989
+ if (deck) footer(s, { deck, n, total, dark });
990
+ return s;
991
+ }
992
+
993
+ /* ------------------------------------------------------------------ export */
994
+
995
+
996
+ module.exports = {
997
+ VERSION: "0.8.0",
998
+ T, F, COLS, ASSETS, radius, theme, autoSize, measure: M,
999
+ background, chrome, footer, title, subtitle, kicker, contentTop,
1000
+ card, iconTile, pill,
1001
+ cover, divider, closing,
1002
+ threeUp, fourUpIcon, sixUpIcon, rows, parity, statQuad, statGrid, band,
1003
+ colCards, featureStats, barRank, bridge, heroQuad, productMap,
1004
+
1005
+ /** Call once, before adding slides. */
1006
+ newDeck(pptxgen) {
1007
+ const pres = new pptxgen();
1008
+ pres.layout = "LAYOUT_16x9"; // 10.000" x 5.625" — do not change
1009
+ return pres;
1010
+ },
1011
+ };
1012
+
1013
+ // Dense enterprise layouts live in their own module to keep this file readable.
1014
+ require("./lib/layouts_dense")(module.exports);
1015
+ require("./lib/layouts_consult")(module.exports);
1016
+ require("./lib/layouts_client")(module.exports);