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/lib/schema.js ADDED
@@ -0,0 +1,399 @@
1
+ /**
2
+ * lib/schema.js — the layout catalog as data.
3
+ *
4
+ * Every layout's fields, their types, and how many items it can actually draw.
5
+ * Three things read this and would otherwise each carry their own copy:
6
+ *
7
+ * validate catches a malformed spec before the build, with a path
8
+ * plan emits a per-slide stub with the right fields already in it
9
+ * layouts prints the catalog, so an operator never has to guess a key
10
+ *
11
+ * The item caps are not advice. They are the `.slice(0, n)` in the layout —
12
+ * a 7th item on a sixUpIcon is silently dropped, and coverage is what finds
13
+ * out, three steps later. Declaring them here makes it an error instead.
14
+ */
15
+
16
+ const G = require("./shared").glyphs;
17
+
18
+ const s = (type, o = {}) => ({ type, ...o });
19
+ const str = (o) => s("string", o);
20
+ const req = (o) => ({ required: true, ...o });
21
+
22
+ /** Fields every layout takes. The full-bleed three drop the footer set. */
23
+ const COMMON = {
24
+ tag: str({ note: "caps label, top-left" }),
25
+ dark: s("boolean", { note: "true dark, false light — alternate across the deck" }),
26
+ titleText: s("title", { note: "string, or [before, accentPhrase, after] for the accent-italic device" }),
27
+ subtitleText: str({ note: "the lede under the title" }),
28
+ deck: str({ note: "overrides the deck-level footer" }),
29
+ confidential: str(),
30
+ };
31
+
32
+ const L = {
33
+ /* ------------------------------------------------------------ structural */
34
+ cover: {
35
+ use: "Slide 1. Dark, full bleed, no footer",
36
+ bleed: true,
37
+ fields: {
38
+ eyebrow: str(), body: str(), kickerPlain: str(), kickerBold: str(),
39
+ titleLines: s("string[]", req({ max: 3, note: "one entry per line; auto-shrinks 46→38pt" })),
40
+ },
41
+ },
42
+ divider: {
43
+ use: "Section breaks and the agenda. Keeps the tag, drops the footer",
44
+ bleed: true,
45
+ fields: { eyebrow: str(), titleText: s("title", req()), body: str() },
46
+ },
47
+ closing: {
48
+ use: "Last slide. Centred, full bleed",
49
+ bleed: true,
50
+ fields: { eyebrow: str(), line1: str(req()), line2: str(), body: str(), url: str() },
51
+ },
52
+
53
+ /* --------------------------------------------------------------- content */
54
+ threeUp: {
55
+ use: "3 comparable things. `arrows: true` for a progression",
56
+ fields: {
57
+ arrows: s("boolean"),
58
+ items: s("object[]", req({ min: 2, max: 3, of: {
59
+ eyebrow: str(), title: str(req()), pill: str(), body: str(), emph: s("boolean"),
60
+ } })),
61
+ },
62
+ },
63
+ fourUpIcon: {
64
+ use: "4 attributes or capabilities",
65
+ fields: {
66
+ kickerText: str(),
67
+ items: s("object[]", req({ min: 3, max: 4, of: { icon: str(), title: str(req()), body: str() } })),
68
+ },
69
+ },
70
+ sixUpIcon: {
71
+ use: "6 features. Don't use it three slides running",
72
+ fields: {
73
+ items: s("object[]", req({ min: 4, max: 6, of: {
74
+ icon: str(), title: str(req()), body: str(), note: str({ note: "accent line under the body — a target or figure" }),
75
+ } })),
76
+ },
77
+ },
78
+ rows: {
79
+ use: "Stack / ownership tables. `ours: false` gets the dashed bring-your-own treatment",
80
+ fields: {
81
+ items: s("object[]", req({ min: 2, max: 5, of: {
82
+ icon: str(), label: str(req()), description: str(), badge: str(), ours: s("boolean"),
83
+ } })),
84
+ },
85
+ },
86
+ parity: {
87
+ use: "Two-sided comparison, list-shaped. The contrast between the cards is the point",
88
+ fields: {
89
+ badge: str({ note: 'the glyph in the centre circle, default "="' }),
90
+ badgeNote: str({ note: "caption under the centre circle" }),
91
+ kickerText: str(),
92
+ left: s("object", req({ of: { eyebrow: str(), icon: str(), label: str(req()), items: s("string[]", req({ max: 5 })) } })),
93
+ right: s("object", req({ of: { eyebrow: str(), icon: str(), label: str(req()), items: s("string[]", req({ max: 5 })) } })),
94
+ },
95
+ },
96
+ colCards: {
97
+ use: "2–3 layers, each with prose and a bullet list",
98
+ fields: {
99
+ items: s("object[]", req({ min: 2, max: 3, of: {
100
+ art: str(), eyebrow: str(), title: str(req()), sub: str(), body: str(), bullets: s("string[]"),
101
+ } })),
102
+ },
103
+ },
104
+ numberedCards: {
105
+ use: "Three strategic priorities — where the ordinal is the organising device",
106
+ fields: {
107
+ items: s("object[]", req({ min: 2, max: 3, of: {
108
+ eyebrow: str(), number: str(), title: str(req()), badge: str(), body: str(), emph: s("boolean"),
109
+ } })),
110
+ },
111
+ },
112
+ phaseCards: {
113
+ use: "4-step roadmaps. Card 1 is the emphasis card",
114
+ fields: {
115
+ phases: s("object[]", req({ min: 2, max: 4, of: {
116
+ period: str(), title: str(req()), bullets: s("string[]", { max: 5 }),
117
+ } })),
118
+ },
119
+ },
120
+ archStack: {
121
+ use: "Layered architecture: actors on top, your governed layer in the middle, vendors below, outcome at the base",
122
+ fields: {
123
+ top: s("object", req({ of: { label: str(req()), body: str() } })),
124
+ middle: s("object", req({ of: {
125
+ label: str(), modules: s("object[]", { max: 6, of: { title: str(req()), body: str() },
126
+ note: "empty renders the band as a single centred connector line" }),
127
+ } })),
128
+ note: str({ note: "one line under the module row" }),
129
+ sources: s("object", { note: "omit entirely for a stack that ends at the outcome band", of: {
130
+ eyebrow: str(), cards: s("object[]", { max: 4, of: { title: str(req()), body: str(), ours: s("boolean") } }),
131
+ } }),
132
+ outcome: str(),
133
+ },
134
+ },
135
+
136
+ /* ------------------------------------------------------------------ data */
137
+ statQuad: {
138
+ use: "4 standalone figures",
139
+ fields: {
140
+ kickerText: str(),
141
+ items: s("object[]", req({ min: 2, max: 4, of: { value: str(req()), caption: str() } })),
142
+ },
143
+ },
144
+ statGrid: {
145
+ use: "6 figures. Card 1 can be the emphasis card",
146
+ fields: {
147
+ footnote: str(),
148
+ items: s("object[]", req({ min: 3, max: 6, of: {
149
+ value: str(req()), label: str(), caption: str(), emph: s("boolean"),
150
+ } })),
151
+ },
152
+ },
153
+ featureStats: {
154
+ use: "One assertion card plus 3 supporting figures",
155
+ fields: {
156
+ lead: s("object", req({ of: { icon: str(), runs: s("object[]", req({ of: { text: str(req()), accent: s("boolean") } })) } })),
157
+ stats: s("object[]", req({ min: 2, max: 3, of: { value: str(req()), caption: str() } })),
158
+ },
159
+ },
160
+ barRank: {
161
+ use: "Ranked magnitudes — TAM ladders, market sizing. Not addChart()",
162
+ fields: {
163
+ footnote: str(),
164
+ bars: s("object[]", req({ min: 2, max: 4, of: {
165
+ label: str(req()), value: str(), frac: s("number", req({ note: "0–1, bar length" })),
166
+ } })),
167
+ cards: s("object[]", { max: 3, of: { icon: str(), value: str(), caption: str() } }),
168
+ },
169
+ },
170
+ bridge: {
171
+ use: "Margin bridges and waterfalls",
172
+ fields: {
173
+ eyebrow: str(),
174
+ rows: s("object[]", req({ min: 2, max: 5, of: { label: str(req()), value: str(), frac: s("number", req()) } })),
175
+ stats: s("object[]", { max: 4, of: { value: str(req()), label: str(), caption: str() } }),
176
+ },
177
+ },
178
+ band: {
179
+ use: "An equation or single assertion, usually over a stat row",
180
+ fields: {
181
+ icon: str(), quote: str(),
182
+ runs: s("object[]", req({ of: { text: str(req()), accent: s("boolean") } })),
183
+ stats: s("object[]", { max: 4, of: { value: str(req()), caption: str() } }),
184
+ },
185
+ },
186
+ matrixGrid: {
187
+ use: "Categorised inventories — use-case maps, capability grids",
188
+ fields: {
189
+ kickerText: str(),
190
+ groups: s("object[]", req({ min: 2, max: 4, of: {
191
+ category: str(req()), items: s("object[]", req({ max: 3, of: { title: str(req()), body: str() } })),
192
+ } })),
193
+ },
194
+ },
195
+ logoBands: {
196
+ use: "Proof points: figures over customer / investor name rows",
197
+ fields: {
198
+ stats: s("object[]", req({ min: 2, max: 4, of: { value: str(req()), caption: str() } })),
199
+ bands: s("object[]", req({ min: 1, max: 2, of: { label: str(req()), names: str(req()), note: str() } })),
200
+ },
201
+ },
202
+ heroQuad: {
203
+ use: "One headline figure plus a 2×2 detail grid",
204
+ fields: {
205
+ kickerText: str(),
206
+ hero: s("object", req({ of: {
207
+ pill: str(), bigValue: str(req()), bigCaption: str(), subValue: str(), subCaption: str(),
208
+ } })),
209
+ tiles: s("object[]", req({ min: 2, max: 4, of: { icon: str(), title: str(req()), body: str() } })),
210
+ },
211
+ },
212
+ productMap: {
213
+ use: "Rail-labelled product architecture (MANAGE / EXECUTE / WORKFORCE)",
214
+ fields: {
215
+ bandRow: s("object", req({ of: { rail: str(), icon: str(), title: str(req()), body: str() } })),
216
+ cards: s("object[]", req({ min: 2, max: 3, of: {
217
+ icon: str(), title: str(req()), body: str(), bullets: s("string[]"),
218
+ } })),
219
+ split: s("object", { of: { rail: str(), left: str(), right: str() } }),
220
+ base: str(),
221
+ },
222
+ },
223
+
224
+ /* -------------------------------------------------------------- consult */
225
+ problemGrid: {
226
+ use: "The challenge slide: a strip of today's reality, then the problems it creates",
227
+ fields: {
228
+ strip: s("object", req({ of: {
229
+ label: str(), chips: s("object[]", req({ max: 4, of: { title: str(req()), body: str() } })),
230
+ } })),
231
+ problems: s("object", req({ of: {
232
+ label: str(), items: s("object[]", req({ max: 4, of: { title: str(req()), body: str(req()) } })),
233
+ } })),
234
+ },
235
+ },
236
+ splitPanel: {
237
+ use: "One half argument, one half evidence",
238
+ fields: {
239
+ left: s("object", req({ of: { eyebrow: str(), heading: str(req()), body: str(), note: str() } })),
240
+ right: s("object", req({ of: {
241
+ eyebrow: str(), heading: str(),
242
+ items: s("object[]", req({ max: 4, of: { label: str(), body: str(req()) } })),
243
+ } })),
244
+ },
245
+ },
246
+ compareTable: {
247
+ use: "Reason cards over a status-quo vs ours comparison",
248
+ fields: {
249
+ reasons: s("object[]", req({ min: 2, max: 3, of: { title: str(req()), body: str() } })),
250
+ table: s("object", req({ of: {
251
+ leftHead: str(req()), rightHead: str(req()),
252
+ rows: s("object[]", req({ max: 4, of: { label: str(req()), left: str(req()), right: str(req()) } })),
253
+ } })),
254
+ },
255
+ },
256
+ numberedList: {
257
+ use: "Agendas, asks, what we'll cover",
258
+ fields: {
259
+ columns: s("number", { note: "1 or 2" }),
260
+ signoff: str(),
261
+ items: s("object[]", req({ min: 2, max: 6, of: {
262
+ title: str({ note: "omit when the source string cannot be split without inventing a separator" }),
263
+ body: str(req()),
264
+ } })),
265
+ closing: s("object", { of: { line1: str(), line2: str() } }),
266
+ },
267
+ },
268
+ /* --------------------------------------------------------------- client */
269
+ stripGrid: {
270
+ use: "A strip of figures, a labelled column grid, and a closing band — a year page, a scorecard, a memory model",
271
+ fields: {
272
+ stats: s("object[]", { max: 6, of: { value: str(req()), label: str(), note: str() } }),
273
+ lead: s("object", { of: { label: str(), text: str() } }),
274
+ columns: s("object[]", { max: 4, of: {
275
+ label: str(), items: s("object[]", req({ max: 6, of: { title: str(), body: str(req()) } })),
276
+ } }),
277
+ close: s("object", { of: { label: str(), body: str(req()) } }),
278
+ },
279
+ },
280
+ compareGrid: {
281
+ use: "A comparison wider than compareTable: up to 3 headed columns over up to 6 labelled rows",
282
+ fields: {
283
+ heads: s("string[]", { max: 3 }),
284
+ rows: s("object[]", req({ min: 2, max: 6, of: {
285
+ label: str(),
286
+ cells: s("object[]", req({ max: 3, of: { lead: str(), body: str() } })),
287
+ } })),
288
+ },
289
+ },
290
+ };
291
+
292
+ /* ---------------------------------------------------------------- validate */
293
+
294
+ const TYPEOF = {
295
+ string: (v) => typeof v === "string",
296
+ number: (v) => typeof v === "number" && Number.isFinite(v),
297
+ boolean: (v) => typeof v === "boolean",
298
+ title: (v) => typeof v === "string" || (Array.isArray(v) && v.every((x) => typeof x === "string")),
299
+ "string[]": (v) => Array.isArray(v) && v.every((x) => typeof x === "string"),
300
+ "object[]": (v) => Array.isArray(v) && v.every((x) => x && typeof x === "object" && !Array.isArray(x)),
301
+ object: (v) => v && typeof v === "object" && !Array.isArray(v),
302
+ };
303
+
304
+ function checkFields(defs, value, path, errs, warns) {
305
+ for (const [key, def] of Object.entries(defs)) {
306
+ const v = value[key];
307
+ if (v == null || v === "") {
308
+ if (def.required) errs.push(`${path}.${key} is required${def.note ? ` — ${def.note}` : ""}`);
309
+ continue;
310
+ }
311
+ if (!TYPEOF[def.type](v)) {
312
+ errs.push(`${path}.${key} must be ${def.type}, got ${Array.isArray(v) ? "array" : typeof v}`);
313
+ continue;
314
+ }
315
+ if (def.type === "title" && Array.isArray(v) && v.length !== 3) {
316
+ warns.push(`${path}.${key} is a ${v.length}-part array — the accent-italic device expects [before, accent, after]`);
317
+ }
318
+ // An icon is either a named glyph or a legacy PNG filename. A typo used to
319
+ // reach the build and come out as a missing-asset error, or worse, silently
320
+ // resolve to a plausible wrong glyph.
321
+ if (key === "icon" && typeof v === "string" && !/\.\w+$/.test(v) && !G.has(v)) {
322
+ errs.push(`${path}.icon "${v}" is not a glyph — did you mean ${G.suggest(v).join(" / ")}? (\`node wand.js glyphs\` lists them)`);
323
+ }
324
+ if (Array.isArray(v)) {
325
+ if (def.min && v.length < def.min) errs.push(`${path}.${key} needs at least ${def.min} items, has ${v.length}`);
326
+ if (def.max && v.length > def.max) {
327
+ errs.push(`${path}.${key} draws at most ${def.max} items — ${v.length - def.max} would be silently dropped`);
328
+ }
329
+ if (def.of) v.forEach((item, i) => checkFields(def.of, item, `${path}.${key}[${i}]`, errs, warns));
330
+ } else if (def.type === "object" && def.of) {
331
+ checkFields(def.of, v, `${path}.${key}`, errs, warns);
332
+ }
333
+ }
334
+ for (const key of Object.keys(value)) {
335
+ if (key.startsWith("_") || key in defs || key in COMMON || key === "layout" || key === "n" || key === "total") continue;
336
+ warns.push(`${path}.${key} is not a field of this layout — it will be ignored`);
337
+ }
338
+ }
339
+
340
+ /** Validate a whole deck spec. Returns {errors, warnings}. */
341
+ function validate(spec) {
342
+ const errs = [], warns = [];
343
+ if (!spec || typeof spec !== "object") return { errors: ["spec is not an object"], warnings: [] };
344
+ if (!Array.isArray(spec.slides) || !spec.slides.length) {
345
+ return { errors: ["spec has no `slides` array"], warnings: [] };
346
+ }
347
+ if (!spec.deck) warns.push("spec.deck is unset — the footer will be blank");
348
+
349
+ const seen = [];
350
+ spec.slides.forEach((sl, i) => {
351
+ const path = `slides[${i}] (S${String(i + 1).padStart(2, "0")})`;
352
+ if (!sl.layout) return errs.push(`${path}.layout is required`);
353
+ const def = L[sl.layout];
354
+ if (!def) {
355
+ const near = Object.keys(L).filter((k) => k.toLowerCase().startsWith(sl.layout.slice(0, 3).toLowerCase()));
356
+ return errs.push(`${path}.layout "${sl.layout}" is not a layout${near.length ? ` — did you mean ${near.join(" / ")}?` : ""}`);
357
+ }
358
+ if (sl.layout === "TODO") return;
359
+ checkFields({ ...COMMON, ...def.fields }, sl, path, errs, warns);
360
+ seen.push(sl.layout);
361
+ });
362
+
363
+ // §5: vary the layouts, alternate the themes
364
+ const distinct = new Set(seen).size;
365
+ if (spec.slides.length >= 10 && distinct < 8) {
366
+ warns.push(`only ${distinct} distinct layouts across ${spec.slides.length} slides — §5 asks for at least 8`);
367
+ }
368
+ for (let i = 2; i < seen.length; i++) {
369
+ if (seen[i] === seen[i - 1] && seen[i] === seen[i - 2]) {
370
+ warns.push(`${seen[i]} runs three slides straight around S${i + 1} — §5 calls that a failure even if each one is right`);
371
+ break;
372
+ }
373
+ }
374
+ return { errors: errs, warnings: warns };
375
+ }
376
+
377
+ /** A blank spec stub for a layout, required fields only. */
378
+ function stub(layout) {
379
+ const def = L[layout];
380
+ if (!def) return null;
381
+ const out = { layout };
382
+ if (!def.bleed) { out.tag = ""; out.dark = true; }
383
+ out.titleText = "";
384
+ const fill = (defs) => {
385
+ const o = {};
386
+ for (const [k, d] of Object.entries(defs)) {
387
+ if (!d.required) continue;
388
+ if (d.type === "object[]") o[k] = [fill(d.of || {})];
389
+ else if (d.type === "object") o[k] = fill(d.of || {});
390
+ else if (d.type === "string[]") o[k] = [""];
391
+ else o[k] = d.type === "boolean" ? false : d.type === "number" ? 0 : "";
392
+ }
393
+ return o;
394
+ };
395
+ Object.assign(out, fill(def.fields));
396
+ return out;
397
+ }
398
+
399
+ module.exports = { LAYOUTS: L, COMMON, validate, stub };
package/lib/shared.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * lib/shared.js — the brand-level libraries, resolved through brand.js.
3
+ *
4
+ * measure.js and glyphs.js are about Geist and the icon set, not about decks,
5
+ * so they live in brand/lib and both kits load them from here. A local copy
6
+ * would be a second source of truth for text metrics, which is the one thing
7
+ * this system cannot afford.
8
+ */
9
+ const path = require("path");
10
+ const LIB = require("./brand").resolve("lib");
11
+ module.exports = {
12
+ measure: require(path.join(LIB, "measure.js"))(require("fontkit")),
13
+ glyphs: require(path.join(LIB, "glyphs.js")),
14
+ pdf: require(path.join(LIB, "pdf.js")),
15
+ };
package/lib/trace.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * lib/trace.js — run a deck spec through the real layout functions, but record
3
+ * the shape calls instead of writing a .pptx.
4
+ *
5
+ * The point: the HTML preview must not re-implement any layout. If it did, the
6
+ * preview and the deck would drift the moment one was edited. Instead the same
7
+ * `cover()` / `archStack()` / etc. run against a fake presentation object that
8
+ * writes down every addShape / addText / addImage with its geometry. The
9
+ * browser then renders that trace. One source of truth for position, forever.
10
+ */
11
+
12
+ function fakeSlide() {
13
+ const shapes = [];
14
+ const api = {
15
+ _shapes: shapes,
16
+ background: null,
17
+ addShape(type, o) { shapes.push({ kind: "shape", type, ...o }); return api; },
18
+ addImage(o) { shapes.push({ kind: "image", ...o }); return api; },
19
+ addText(t, o) { shapes.push({ kind: "text", runs: normaliseRuns(t), ...o }); return api; },
20
+ addNotes() { return api; },
21
+ addChart() { return api; },
22
+ };
23
+ Object.defineProperty(api, "background", {
24
+ get() { return api._bg; },
25
+ set(v) { api._bg = v; },
26
+ enumerable: true, configurable: true,
27
+ });
28
+ return api;
29
+ }
30
+
31
+ /** pptxgenjs accepts a string or an array of {text, options}. Flatten to one shape. */
32
+ function normaliseRuns(t) {
33
+ if (typeof t === "string" || typeof t === "number") return [{ text: String(t), options: {} }];
34
+ if (Array.isArray(t)) return t.map((r) => ({ text: String(r.text ?? ""), options: r.options || {} }));
35
+ return [{ text: String(t?.text ?? ""), options: t?.options || {} }];
36
+ }
37
+
38
+ function fakePres() {
39
+ const slides = [];
40
+ return {
41
+ slides,
42
+ layout: null,
43
+ addSlide() { const s = fakeSlide(); slides.push(s); return s; },
44
+ writeFile() { return Promise.resolve(); },
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Run a spec through the kit and return a serialisable trace.
50
+ * `spec` is the same deck.json the pptx build consumes.
51
+ */
52
+ function trace(W, spec) {
53
+ const pres = fakePres();
54
+ pres.layout = "LAYOUT_16x9";
55
+ const total = spec.slides.length;
56
+ let n = 0;
57
+ const errors = [];
58
+
59
+ spec.slides.forEach((sl, i) => {
60
+ const fn = W[sl.layout];
61
+ if (typeof fn !== "function") {
62
+ errors.push(`slide ${i + 1}: unknown layout "${sl.layout}"`);
63
+ return;
64
+ }
65
+ const fullBleed = ["cover", "divider", "closing"].includes(sl.layout);
66
+ n += 1;
67
+ const args = { ...sl, n, total };
68
+ if (!fullBleed) {
69
+ args.deck = sl.deck || spec.deck;
70
+ args.confidential = sl.confidential || spec.confidential;
71
+ }
72
+ try { fn(pres, args); }
73
+ catch (e) { errors.push(`slide ${i + 1} (${sl.layout}): ${e.message}`); }
74
+ });
75
+
76
+ return {
77
+ canvas: { w: 10, h: 5.625 },
78
+ errors,
79
+ slides: pres.slides.map((s, i) => ({
80
+ index: i + 1,
81
+ layout: spec.slides[i]?.layout,
82
+ background: s._bg || null,
83
+ shapes: s._shapes,
84
+ })),
85
+ };
86
+ }
87
+
88
+ module.exports = { trace, fakePres, fakeSlide };
package/lib/ttf.py ADDED
@@ -0,0 +1,167 @@
1
+ """
2
+ ttf.py — the ~1% of TrueType that embed_fonts.py needs, with no fontTools.
3
+
4
+ Reads `head` and `OS/2` for the fields an EOT header carries, and rewrites the
5
+ `name` table so a face reports the family we address it by. Weight in this
6
+ system is carried by the family name — `Geist SemiBold` regular, not `Geist`
7
+ bold — so retagging is not cosmetic: without it PowerPoint resolves four of the
8
+ five families to the same face.
9
+
10
+ Everything else in the font is copied through untouched.
11
+ """
12
+
13
+ import struct
14
+
15
+ _HEAD_CHECKSUM_ADJ = 8 # offset of checkSumAdjustment within `head`
16
+ _MAGIC = 0xB1B0AFBA
17
+
18
+
19
+ class TTFError(ValueError):
20
+ pass
21
+
22
+
23
+ class TTF:
24
+ def __init__(self, data):
25
+ if len(data) < 12:
26
+ raise TTFError("not a font: too short")
27
+ tag = data[:4]
28
+ if tag not in (b"\x00\x01\x00\x00", b"true", b"ttcf", b"OTTO"):
29
+ raise TTFError(f"unrecognised sfnt tag {tag!r}")
30
+ if tag == b"ttcf":
31
+ raise TTFError("font collections (.ttc) are not supported — pass a single face")
32
+ self.data = data
33
+ self.sfnt = tag
34
+ (num,) = struct.unpack(">H", data[4:6])
35
+ self.tables = {}
36
+ for i in range(num):
37
+ off = 12 + i * 16
38
+ t, csum, o, ln = struct.unpack(">4sIII", data[off:off + 16])
39
+ self.tables[t] = (o, ln, csum)
40
+
41
+ # -- reading -----------------------------------------------------------
42
+
43
+ def table(self, tag):
44
+ if isinstance(tag, str):
45
+ tag = tag.encode()
46
+ if tag not in self.tables:
47
+ raise TTFError(f"font has no {tag.decode()} table")
48
+ o, ln, _ = self.tables[tag]
49
+ return self.data[o:o + ln]
50
+
51
+ @property
52
+ def check_sum_adjustment(self):
53
+ return struct.unpack(">I", self.table("head")[8:12])[0]
54
+
55
+ @property
56
+ def os2(self):
57
+ """The OS/2 fields the EOT header needs."""
58
+ t = self.table("OS/2")
59
+ version = struct.unpack(">H", t[0:2])[0]
60
+ d = {
61
+ "version": version,
62
+ "usWeightClass": struct.unpack(">H", t[4:6])[0],
63
+ "panose": t[32:42],
64
+ "ulUnicodeRange": struct.unpack(">IIII", t[42:58]),
65
+ "fsSelection": struct.unpack(">H", t[62:64])[0],
66
+ }
67
+ # code page ranges only exist from version 1
68
+ d["ulCodePageRange"] = struct.unpack(">II", t[78:86]) if version >= 1 and len(t) >= 86 else (0, 0)
69
+ return d
70
+
71
+ def names(self):
72
+ """{nameID: string} for the Windows/Unicode records, best effort."""
73
+ t = self.table("name")
74
+ count, storage = struct.unpack(">HH", t[2:6])
75
+ out = {}
76
+ for i in range(count):
77
+ p = 6 + i * 12
78
+ plat, enc, lang, nid, ln, off = struct.unpack(">HHHHHH", t[p:p + 12])
79
+ raw = t[storage + off: storage + off + ln]
80
+ try:
81
+ s = raw.decode("utf-16-be") if plat == 3 or (plat == 0) else raw.decode("latin-1")
82
+ except UnicodeDecodeError:
83
+ continue
84
+ out.setdefault(nid, s)
85
+ return out
86
+
87
+ # -- writing -----------------------------------------------------------
88
+
89
+ def retagged(self, family, subfamily, full, postscript):
90
+ """Return new font bytes with name IDs 1/2/4/6 replaced."""
91
+ replace = {1: family, 2: subfamily, 4: full, 6: postscript}
92
+ name = _rebuild_name(self.table("name"), replace)
93
+ return _reassemble(self, {b"name": name})
94
+
95
+
96
+ def _rebuild_name(t, replace):
97
+ count, storage = struct.unpack(">HH", t[2:6])
98
+ records, strings, cursor = [], bytearray(), 0
99
+
100
+ for i in range(count):
101
+ p = 6 + i * 12
102
+ plat, enc, lang, nid, ln, off = struct.unpack(">HHHHHH", t[p:p + 12])
103
+ if nid in replace:
104
+ s = replace[nid]
105
+ raw = s.encode("utf-16-be") if plat in (0, 3) else s.encode("latin-1", "replace")
106
+ else:
107
+ raw = t[storage + off: storage + off + ln]
108
+ records.append((plat, enc, lang, nid, len(raw), cursor))
109
+ strings += raw
110
+ cursor += len(raw)
111
+
112
+ # sort order is required by the spec: platform, encoding, language, name
113
+ records.sort(key=lambda r: (r[0], r[1], r[2], r[3]))
114
+ head = struct.pack(">HHH", 0, len(records), 6 + len(records) * 12)
115
+ body = b"".join(struct.pack(">HHHHHH", *r) for r in records)
116
+ return head + body + bytes(strings)
117
+
118
+
119
+ def _checksum(b):
120
+ if len(b) % 4:
121
+ b = b + b"\0" * (4 - len(b) % 4)
122
+ return sum(struct.unpack(f">{len(b)//4}I", b)) & 0xFFFFFFFF
123
+
124
+
125
+ def _reassemble(font, overrides):
126
+ """Rewrite the whole sfnt with some tables replaced, fixing every offset,
127
+ every table checksum and the head checkSumAdjustment."""
128
+ tags = sorted(font.tables)
129
+ parts = {}
130
+ for t in tags:
131
+ parts[t] = overrides.get(t, font.table(t))
132
+
133
+ # head's checkSumAdjustment must be zero while the file checksum is taken
134
+ head = bytearray(parts[b"head"])
135
+ head[_HEAD_CHECKSUM_ADJ:_HEAD_CHECKSUM_ADJ + 4] = b"\0\0\0\0"
136
+ parts[b"head"] = bytes(head)
137
+
138
+ n = len(tags)
139
+ # binary-search fields the spec wants present; readers ignore them but
140
+ # validators complain when they are wrong
141
+ entry_selector = max(n.bit_length() - 1, 0)
142
+ search_range = (1 << entry_selector) * 16
143
+ out = bytearray(struct.pack(">4sHHHH", font.sfnt, n, search_range,
144
+ entry_selector, n * 16 - search_range))
145
+
146
+ offset = 12 + n * 16
147
+ directory, body = bytearray(), bytearray()
148
+ for t in tags:
149
+ d = parts[t]
150
+ directory += struct.pack(">4sIII", t, _checksum(d), offset, len(d))
151
+ body += d
152
+ pad = (-len(d)) % 4
153
+ body += b"\0" * pad
154
+ offset += len(d) + pad
155
+
156
+ out += directory + body
157
+ adj = (_MAGIC - _checksum(bytes(out))) & 0xFFFFFFFF
158
+
159
+ head_off = struct.unpack(">I", out[12 + tags.index(b"head") * 16 + 8:
160
+ 12 + tags.index(b"head") * 16 + 12])[0]
161
+ out[head_off + _HEAD_CHECKSUM_ADJ: head_off + _HEAD_CHECKSUM_ADJ + 4] = struct.pack(">I", adj)
162
+ return bytes(out)
163
+
164
+
165
+ def load(path):
166
+ with open(path, "rb") as f:
167
+ return TTF(f.read())