wand-decks-kit 0.9.0 → 0.11.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.
@@ -665,6 +665,106 @@
665
665
  }
666
666
  ]
667
667
  },
668
+ {
669
+ "layout": "flowCompare",
670
+ "tag": "Why three years of AI spend has not moved the operating line",
671
+ "titleText": "Every AI tool you own ends at a person.",
672
+ "lanes": [
673
+ {
674
+ "label": "Today — AI as a tool",
675
+ "caption": "Capacity = your people. Four of five steps wait on a person; that person is the ceiling.",
676
+ "steps": [
677
+ {
678
+ "who": "Person",
679
+ "step": "Notices",
680
+ "human": true
681
+ },
682
+ {
683
+ "who": "Person",
684
+ "step": "Gathers context",
685
+ "human": true
686
+ },
687
+ {
688
+ "who": "Person",
689
+ "step": "Instructs",
690
+ "human": true
691
+ },
692
+ {
693
+ "who": "AI",
694
+ "step": "Drafts"
695
+ },
696
+ {
697
+ "who": "Person",
698
+ "step": "Checks",
699
+ "human": true
700
+ }
701
+ ]
702
+ },
703
+ {
704
+ "label": "With Wand — AI as labor",
705
+ "caption": "Capacity = people + agents. The person moves from every step to the one step that needs judgment.",
706
+ "steps": [
707
+ {
708
+ "who": "Agent",
709
+ "step": "Notices"
710
+ },
711
+ {
712
+ "who": "Agent",
713
+ "step": "Plans"
714
+ },
715
+ {
716
+ "who": "Agent",
717
+ "step": "Executes"
718
+ },
719
+ {
720
+ "who": "Agent",
721
+ "step": "Grades"
722
+ },
723
+ {
724
+ "who": "Manager",
725
+ "step": "Approves",
726
+ "human": true
727
+ }
728
+ ]
729
+ }
730
+ ]
731
+ },
732
+ {
733
+ "layout": "timeline",
734
+ "tag": "Transformation journey",
735
+ "titleText": "The transformation timeline.",
736
+ "subtitleText": "From the starting line, month over month, compounding.",
737
+ "phases": [
738
+ {
739
+ "when": "3–6 months",
740
+ "label": "Foundation",
741
+ "bullets": [
742
+ "A core transformation team of 7–10 people",
743
+ "One registry covering people and agents",
744
+ "5–10 core processes rebuilt for the dual workforce",
745
+ "Dozens of agents in production with named managers"
746
+ ]
747
+ },
748
+ {
749
+ "when": "6–12 months",
750
+ "label": "Scale",
751
+ "bullets": [
752
+ "Agents across dozens of core functions",
753
+ "Management structure rebalanced; spans widened",
754
+ "Hundreds of agents in production"
755
+ ]
756
+ },
757
+ {
758
+ "when": "12–24 months",
759
+ "label": "Accelerate",
760
+ "bullets": [
761
+ "Most processes designed for a dual workforce from the start",
762
+ "Whole functions run by agent teams",
763
+ "Cost per unit of work structurally below the market"
764
+ ]
765
+ }
766
+ ]
767
+ },
668
768
  {
669
769
  "layout": "closing",
670
770
  "eyebrow": "THE CLOSE",
@@ -215,7 +215,7 @@ module.exports = function attach(W) {
215
215
  });
216
216
  s.addShape("roundRect", {
217
217
  x: L + LAB, y: ry, w: COL, h: trh, rectRadius: radius(COL, trh),
218
- fill: { color: dark ? T.cardMuted : T.surface }, line: { width: 0 },
218
+ fill: { color: dark ? T.cardMuted : T.surface }, line: { type: "none" },
219
219
  });
220
220
  s.addText(row.left, {
221
221
  x: L + LAB + 0.18, y: ry, w: COL - 0.36, h: trh, margin: 0, isTextBox: true,
package/lib/pdf.js CHANGED
@@ -112,6 +112,37 @@ class Doc {
112
112
  return this;
113
113
  }
114
114
 
115
+ /**
116
+ * A filled triangle in its box, pointing up — or right when rotated 90°, the
117
+ * way a flow connector is drawn. Matches pptxgenjs's "triangle" + rotate.
118
+ */
119
+ triangle({ x, y, w, h, fill, rotate = 0 }) {
120
+ const o = this.page.ops;
121
+ const pts = rotate === 90
122
+ ? [[x, y], [x + w, y + h / 2], [x, y + h]]
123
+ : [[x, y + h], [x + w / 2, y], [x + w, y + h]];
124
+ o.push(`${rgb(fill || "000000")} rg`,
125
+ pts.map(([px, py], i) => `${num(px * PT)} ${num(this._y(py))} ${i ? "l" : "m"}`).join(" ") + " h f");
126
+ return this;
127
+ }
128
+
129
+ /** A filled polygon, optionally translucent — an area chart's fill. */
130
+ poly({ points, fill, opacity }) {
131
+ const o = this.page.ops;
132
+ const soft = opacity != null && opacity < 1;
133
+ if (soft) o.push("q", `/${this._alpha(opacity)} gs`);
134
+ o.push(`${rgb(fill || "000000")} rg`,
135
+ points.map(([px, py], i) => `${num(px * PT)} ${num(this._y(py))} ${i ? "l" : "m"}`).join(" ") + " h f");
136
+ if (soft) o.push("Q");
137
+ return this;
138
+ }
139
+
140
+ /** Width of a string in inches, for centring a label without drawing it. */
141
+ measure(str, { stem, size }) {
142
+ const F = this._font(stem);
143
+ return (F.font.layout(String(str)).advanceWidth / F.font.unitsPerEm) * size / 72;
144
+ }
145
+
115
146
  line({ x1, y1, x2, y2, color = "000000", width = 0.75 }) {
116
147
  this.page.ops.push(`${rgb(color)} RG`, `${num(width)} w`,
117
148
  `${num(x1 * PT)} ${num(this._y(y1))} m ${num(x2 * PT)} ${num(this._y(y2))} l S`);
package/lib/render_pdf.js CHANGED
@@ -140,6 +140,7 @@ function renderTrace(trace, out, opts = {}) {
140
140
  if (sh.kind === "shape") drawShape(d, sh);
141
141
  else if (sh.kind === "image") drawImage(d, sh, missing);
142
142
  else if (sh.kind === "text") drawText(d, sh);
143
+ else if (sh.kind === "chart") drawChart(d, sh);
143
144
  }
144
145
  }
145
146
  fs.writeFileSync(out, d.toBuffer());
@@ -152,6 +153,7 @@ function drawShape(d, sh) {
152
153
  const stroke = sh.line && sh.line.width ? (sh.line.color || "000000").replace(/^#/, "") : null;
153
154
  const box = { x: sh.x || 0, y: sh.y || 0, w: sh.w || 0, h: sh.h || 0 };
154
155
  if (sh.type === "ellipse") return d.ellipse({ ...box, fill, stroke, lineWidth: (sh.line && sh.line.width) || 0.75 });
156
+ if (sh.type === "triangle") return d.triangle({ ...box, fill, rotate: sh.rotate || 0 });
155
157
  d.rect({
156
158
  ...box, fill, stroke,
157
159
  lineWidth: (sh.line && sh.line.width) || 0.75,
@@ -161,6 +163,68 @@ function drawShape(d, sh) {
161
163
  });
162
164
  }
163
165
 
166
+ /** Round up to a gridline-friendly maximum: 87 -> 100, 3.2 -> 4, 460 -> 500. */
167
+ function niceMax(v) {
168
+ if (!(v > 0)) return 1;
169
+ const p = Math.pow(10, Math.floor(Math.log10(v))), m = v / p;
170
+ return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p;
171
+ }
172
+
173
+ /**
174
+ * The chart as PowerPoint draws it: legend on top, four gridlines, category
175
+ * labels under the plot. Not pixel-identical to PowerPoint's own layout — close
176
+ * enough that a slide which reads here reads there.
177
+ */
178
+ function drawChart(d, sh) {
179
+ const c = sh.chart || {}, labels = c.labels || [], series = c.series || [];
180
+ const stem = "Geist-Regular", sz = 7;
181
+ const legendH = c.legend ? 0.26 : 0, padL = 0.40, padB = 0.26;
182
+ const px = sh.x + padL, py = sh.y + legendH + 0.06, pw = sh.w - padL - 0.08, ph = sh.h - legendH - padB - 0.06;
183
+ const max = c.yMax || niceMax(Math.max(0, ...series.flatMap((x) => x.values || [])));
184
+ const fmt = (v) => (Math.round(v * 100) / 100).toString();
185
+ for (let k = 0; k <= 4; k++) {
186
+ const gy = py + ph - (ph * k) / 4;
187
+ d.line({ x1: px, y1: gy, x2: px + pw, y2: gy, color: c.grid || "5B4BB0", width: 0.5 });
188
+ const lab = fmt((max * k) / 4);
189
+ d.text(lab, { x: px - 0.08 - d.measure(lab, { stem, size: sz }), y: gy + 0.03, stem, size: sz, color: c.ink || "BBBBBB" });
190
+ }
191
+ const n = labels.length, bar = c.kind === "bar";
192
+ const xAt = (i) => bar ? px + (pw / n) * (i + 0.5) : px + (n > 1 ? (pw * i) / (n - 1) : pw / 2);
193
+ const yAt = (v) => py + ph - (ph * Math.min(v, max)) / max;
194
+ labels.forEach((l, i) => {
195
+ const w = d.measure(l, { stem, size: sz });
196
+ const lx = Math.min(Math.max(xAt(i) - w / 2, px), px + pw - w);
197
+ d.text(l, { x: lx, y: py + ph + 0.17, stem, size: sz, color: c.ink || "BBBBBB" });
198
+ });
199
+ series.forEach((ser, si) => {
200
+ const col = (c.colors && c.colors[si]) || "8B7CF6", vals = ser.values || [];
201
+ if (bar) {
202
+ const slot = pw / n, group = slot * 0.62, bw = group / series.length;
203
+ vals.forEach((v, i) => {
204
+ const bx = px + slot * i + (slot - group) / 2 + bw * si;
205
+ d.rect({ x: bx, y: yAt(v), w: bw * 0.92, h: py + ph - yAt(v), fill: col });
206
+ });
207
+ return;
208
+ }
209
+ if (c.kind === "area") {
210
+ d.poly({ points: [[xAt(0), py + ph], ...vals.map((v, i) => [xAt(i), yAt(v)]), [xAt(vals.length - 1), py + ph]],
211
+ fill: col, opacity: 0.45 });
212
+ }
213
+ if (c.kind === "area") return; // PowerPoint's area chart has no outline
214
+ for (let i = 1; i < vals.length; i++) {
215
+ d.line({ x1: xAt(i - 1), y1: yAt(vals[i - 1]), x2: xAt(i), y2: yAt(vals[i]), color: col, width: ser.emph ? 2.5 : 2 });
216
+ }
217
+ });
218
+ if (c.legend) {
219
+ let lx = px;
220
+ series.forEach((ser, si) => {
221
+ d.rect({ x: lx, y: sh.y + 0.08, w: 0.1, h: 0.1, fill: (c.colors && c.colors[si]) || "8B7CF6" });
222
+ d.text(ser.name, { x: lx + 0.15, y: sh.y + 0.17, stem, size: 8, color: c.ink || "BBBBBB" });
223
+ lx += 0.15 + d.measure(ser.name, { stem, size: 8 }) + 0.3;
224
+ });
225
+ }
226
+ }
227
+
164
228
  function drawImage(d, sh, missing) {
165
229
  const box = { x: sh.x || 0, y: sh.y || 0, w: sh.w || 0, h: sh.h || 0 };
166
230
  if (sh.data) { // a recoloured glyph, inline
package/lib/schema.js CHANGED
@@ -27,6 +27,8 @@ const COMMON = {
27
27
  subtitleText: str({ note: "the lede under the title" }),
28
28
  deck: str({ note: "overrides the deck-level footer" }),
29
29
  confidential: str(),
30
+ notes: s("notes", { note: 'speaker notes — a string, or { time: "00:21 to 00:25", core: true, text, watch }' }),
31
+ backup: s("boolean", { note: "a hidden slide after the closing; its footer says BACKUP" }),
30
32
  };
31
33
 
32
34
  const L = {
@@ -43,7 +45,8 @@ const L = {
43
45
  divider: {
44
46
  use: "Section breaks. Always dark, keeps the tag, drops the footer. Prose goes in `body`",
45
47
  bleed: true,
46
- fields: { eyebrow: str(), titleText: s("title", req()), body: str() },
48
+ fields: { eyebrow: str(), titleText: s("title", req()), body: str(),
49
+ part: str({ note: '"Part II" — set before the body as "Part II · <body>"' }) },
47
50
  },
48
51
  closing: {
49
52
  use: "Last slide. Always dark, centred, full bleed",
@@ -54,7 +57,7 @@ const L = {
54
57
  /* ----------------------------------------------------- guideline (0.9) */
55
58
  agenda: {
56
59
  use: "What we'll cover — 2–5 numbered cards in a row",
57
- fields: { items: s("object[]", req({ min: 2, max: 5, of: { title: str(req()), body: str() } })) },
60
+ fields: { items: s("object[]", req({ min: 2, max: 5, of: { title: str(req()), body: str(), meta: str({ note: '"15 min"' }) } })) },
58
61
  },
59
62
  team: {
60
63
  use: "Who's in the room — 2–4 people, each with a headshot slot the user fills",
@@ -70,7 +73,33 @@ const L = {
70
73
  },
71
74
  coDelivery: {
72
75
  use: "Up to six numbered rows in two columns — a co-delivery model, operating principles",
73
- fields: { items: s("object[]", req({ min: 2, max: 6, of: { title: str(req()), body: str(req()) } })) },
76
+ fields: { items: s("object[]", req({ min: 2, max: 6, of: { title: str(req()), body: str(req()), meta: str({ note: '"15 min" — makes it the session agenda' }) } })) },
77
+ },
78
+ flowCompare: {
79
+ use: "Two lanes of steps — today vs with Wand. Mark the steps a person does `human: true`",
80
+ fields: { lanes: s("object[]", req({ min: 2, max: 2, of: {
81
+ label: str(req()), caption: str(),
82
+ steps: s("object[]", req({ min: 3, max: 5, of: { who: str(), step: str(req()), human: s("boolean") } })),
83
+ } })) },
84
+ },
85
+ chart: {
86
+ use: "A native chart the seller can edit in PowerPoint — line, area or bar — with up to two argument blocks beside or below it",
87
+ fields: {
88
+ kind: str({ note: '"line" (default), "area" or "bar"' }),
89
+ labels: s("string[]", req({ min: 2, max: 12, note: "the categories along the bottom" })),
90
+ series: s("object[]", req({ min: 1, max: 3, of: {
91
+ name: str(req()), values: s("number[]", req()), emph: s("boolean"),
92
+ } })),
93
+ yMax: s("number"), yLabel: str(),
94
+ side: s("object[]", { max: 2, of: { title: str(req()), body: str(req()) } }),
95
+ sidePlace: str({ note: '"right" (default) or "below"' }),
96
+ },
97
+ },
98
+ timeline: {
99
+ use: "A journey in 2–4 phases on a line — months, a label, bullets",
100
+ fields: { phases: s("object[]", req({ min: 2, max: 4, of: {
101
+ when: str(), label: str(req()), bullets: s("string[]", { max: 6 }),
102
+ } })) },
74
103
  },
75
104
 
76
105
  /* --------------------------------------------------------------- content */
@@ -320,8 +349,10 @@ const TYPEOF = {
320
349
  boolean: (v) => typeof v === "boolean",
321
350
  title: (v) => typeof v === "string" || (Array.isArray(v) && v.every((x) => typeof x === "string")),
322
351
  "string[]": (v) => Array.isArray(v) && v.every((x) => typeof x === "string"),
352
+ "number[]": (v) => Array.isArray(v) && v.every((x) => typeof x === "number" && Number.isFinite(x)),
323
353
  "object[]": (v) => Array.isArray(v) && v.every((x) => x && typeof x === "object" && !Array.isArray(x)),
324
354
  object: (v) => v && typeof v === "object" && !Array.isArray(v),
355
+ notes: (v) => typeof v === "string" || (v && typeof v === "object" && !Array.isArray(v)),
325
356
  };
326
357
 
327
358
  function checkFields(defs, value, path, errs, warns) {
@@ -395,6 +426,23 @@ function validate(spec) {
395
426
  seen.push(sl.layout);
396
427
  });
397
428
 
429
+ // a chart whose series do not match its labels draws a line that ends early
430
+ spec.slides.forEach((x, i) => {
431
+ if (x.layout !== "chart" || !Array.isArray(x.labels) || !Array.isArray(x.series)) return;
432
+ x.series.forEach((ser) => {
433
+ if (Array.isArray(ser.values) && ser.values.length !== x.labels.length)
434
+ errs.push(`S${i + 1} chart series "${ser.name}" has ${ser.values.length} values for ${x.labels.length} labels`);
435
+ });
436
+ });
437
+
438
+ // backups live after the close — hidden, so they never interrupt the room
439
+ const closeAt = spec.slides.findIndex((x) => x.layout === "closing");
440
+ spec.slides.forEach((x, i) => {
441
+ if (x.backup && closeAt >= 0 && i < closeAt) warns.push(`S${i + 1} is a backup slide before the closing — move it after S${closeAt + 1}`);
442
+ if (x.notes && typeof x.notes === "object" && x.notes.time && !/\d\d:\d\d/.test(x.notes.time))
443
+ warns.push(`S${i + 1} notes.time "${x.notes.time}" is not a time window — use "00:21 to 00:25"`);
444
+ });
445
+
398
446
  // §5: vary the layouts, alternate the themes
399
447
  const distinct = new Set(seen).size;
400
448
  if (spec.slides.length >= 10 && distinct < 8) {
package/lib/trace.js CHANGED
@@ -18,7 +18,12 @@ function fakeSlide() {
18
18
  addImage(o) { shapes.push({ kind: "image", ...o }); return api; },
19
19
  addText(t, o) { shapes.push({ kind: "text", runs: normaliseRuns(t), ...o }); return api; },
20
20
  addNotes() { return api; },
21
- addChart() { return api; },
21
+ // a chart is recorded by what it shows, so the PDF and preview can draw
22
+ // the same thing PowerPoint will
23
+ addChart(type, data, o = {}) {
24
+ shapes.push({ kind: "chart", type, x: o.x, y: o.y, w: o.w, h: o.h, chart: o._wand || { kind: type, series: data } });
25
+ return api;
26
+ },
22
27
  };
23
28
  Object.defineProperty(api, "background", {
24
29
  get() { return api._bg; },
@@ -52,7 +57,7 @@ function fakePres() {
52
57
  function trace(W, spec) {
53
58
  const pres = fakePres();
54
59
  pres.layout = "LAYOUT_16x9";
55
- const total = spec.slides.length;
60
+ const total = spec.slides.filter((x) => !x.backup).length;
56
61
  let n = 0;
57
62
  const errors = [];
58
63
 
@@ -63,12 +68,12 @@ function trace(W, spec) {
63
68
  return;
64
69
  }
65
70
  const fullBleed = ["cover", "divider", "closing"].includes(sl.layout);
66
- n += 1;
67
- const args = { ...sl, n, total };
71
+ const args = { ...sl, n: sl.backup ? null : ++n, total };
68
72
  if (!fullBleed) {
69
73
  args.deck = sl.deck || spec.deck;
70
74
  args.confidential = sl.confidential || spec.confidential;
71
75
  }
76
+ W.context({ confidential: args.confidential || "STRICTLY CONFIDENTIAL" });
72
77
  try { fn(pres, args); }
73
78
  catch (e) { errors.push(`slide ${i + 1} (${sl.layout}): ${e.message}`); }
74
79
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wand-decks-kit",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Rebuilds or authors a client-ready .pptx on the Wand design system: measured fit, embedded Geist, verbatim content.",
5
5
  "license": "SEE LICENSE IN PROMPT.md",
6
6
  "bin": {
@@ -90,6 +90,8 @@ header input[type="range"] { width: 120px; accent-color: var(--accent); }
90
90
 
91
91
  .sh.rect { border-radius: 0; }
92
92
  .sh.roundRect { border-radius: calc(var(--in) * .10); }
93
+ /* flow connectors: pptxgenjs triangle rotated 90 points right */
94
+ .sh.triangle { clip-path: polygon(0 0, 100% 50%, 0 100%); }
93
95
  .sh.ellipse { border-radius: 50%; }
94
96
 
95
97
  .sh.image { object-fit: contain; }
@@ -95,7 +95,10 @@
95
95
 
96
96
  const s = document.createElement("div");
97
97
  const bg = sl.background && sl.background.color;
98
- s.className = "slide " + (bg && bg.toUpperCase() === "0F0C26" ? "dark" : "light");
98
+ // by luminance, not by one hex: the dark ground moved from 0F0C26 to
99
+ // 240F48 in 0.9 and every dark slide previewed as light
100
+ const lum = bg ? (parseInt(bg.slice(0, 2), 16) * 299 + parseInt(bg.slice(2, 4), 16) * 587 + parseInt(bg.slice(4, 6), 16) * 114) / 1000 : 255;
101
+ s.className = "slide " + (lum < 90 ? "dark" : "light");
99
102
  if (flag.checked) s.classList.add("flag");
100
103
  if (grid.checked) s.classList.add("grid");
101
104
  s.title = sl.layout;
@@ -109,6 +112,8 @@
109
112
  el.src = sh.data ? "data:" + sh.data
110
113
  : sh.path ? (T.assetsBase || "../assets/") + sh.path.split(/[\\/]assets[\\/]/).pop()
111
114
  : "";
115
+ } else if (sh.kind === "chart") {
116
+ el = chartEl(sh);
112
117
  } else if (sh.kind === "text") {
113
118
  el = textEl(sh);
114
119
  } else {
@@ -145,6 +150,49 @@
145
150
  });
146
151
  }
147
152
 
153
+ /** The same chart the PDF draws, as SVG in inches. */
154
+ function chartEl(sh) {
155
+ const c = sh.chart || {}, labels = c.labels || [], series = c.series || [];
156
+ const NS = "http://www.w3.org/2000/svg", W = sh.w, H = sh.h;
157
+ const svg = document.createElementNS(NS, "svg");
158
+ svg.setAttribute("class", "sh chart"); svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
159
+ const add = (tag, a, txt) => { const e = document.createElementNS(NS, tag); for (const k in a) e.setAttribute(k, a[k]); if (txt != null) e.textContent = txt; svg.appendChild(e); return e; };
160
+ const legendH = c.legend ? 0.26 : 0, padL = 0.40, padB = 0.26;
161
+ const px = padL, py = legendH + 0.06, pw = W - padL - 0.08, ph = H - legendH - padB - 0.06;
162
+ const all = series.flatMap((x) => x.values || []);
163
+ const nice = (v) => { if (!(v > 0)) return 1; const p = Math.pow(10, Math.floor(Math.log10(v))), m = v / p; return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p; };
164
+ const max = c.yMax || nice(Math.max(0, ...all)), ink = "#" + (c.ink || "BBBBBB"), grid = "#" + (c.grid || "5B4BB0");
165
+ for (let k = 0; k <= 4; k++) {
166
+ const gy = py + ph - (ph * k) / 4;
167
+ add("line", { x1: px, y1: gy, x2: px + pw, y2: gy, stroke: grid, "stroke-width": 0.007 });
168
+ add("text", { x: px - 0.08, y: gy + 0.03, "text-anchor": "end", "font-size": 0.1, fill: ink, "font-family": "Geist" }, String(Math.round((max * k) / 4 * 100) / 100));
169
+ }
170
+ const n = labels.length, bar = c.kind === "bar";
171
+ const xAt = (i) => bar ? px + (pw / n) * (i + 0.5) : px + (n > 1 ? (pw * i) / (n - 1) : pw / 2);
172
+ const yAt = (v) => py + ph - (ph * Math.min(v, max)) / max;
173
+ labels.forEach((l, i) => add("text", { x: xAt(i), y: py + ph + 0.17, "text-anchor": "middle", "font-size": 0.1, fill: ink, "font-family": "Geist" }, l));
174
+ series.forEach((ser, si) => {
175
+ const col = "#" + ((c.colors && c.colors[si]) || "8B7CF6"), vals = ser.values || [];
176
+ if (bar) {
177
+ const slot = pw / n, group = slot * 0.62, bw = group / series.length;
178
+ vals.forEach((v, i) => add("rect", { x: px + slot * i + (slot - group) / 2 + bw * si, y: yAt(v), width: bw * 0.92, height: py + ph - yAt(v), fill: col }));
179
+ return;
180
+ }
181
+ const pts = vals.map((v, i) => `${xAt(i)},${yAt(v)}`).join(" ");
182
+ if (c.kind === "area") add("polygon", { points: `${xAt(0)},${py + ph} ${pts} ${xAt(vals.length - 1)},${py + ph}`, fill: col, "fill-opacity": 0.45 });
183
+ if (c.kind !== "area") add("polyline", { points: pts, fill: "none", stroke: col, "stroke-width": ser.emph ? 0.035 : 0.028 });
184
+ });
185
+ if (c.legend) {
186
+ let lx = px;
187
+ series.forEach((ser, si) => {
188
+ add("rect", { x: lx, y: 0.08, width: 0.1, height: 0.1, fill: "#" + ((c.colors && c.colors[si]) || "8B7CF6") });
189
+ add("text", { x: lx + 0.15, y: 0.17, "font-size": 0.11, fill: ink, "font-family": "Geist" }, ser.name);
190
+ lx += 0.15 + ser.name.length * 0.065 + 0.3;
191
+ });
192
+ }
193
+ return svg;
194
+ }
195
+
148
196
  /** Browser-side overflow detection: does the laid-out text exceed its box? */
149
197
  function markOverflow(slideEl) {
150
198
  slideEl.querySelectorAll(".sh.text").forEach((el) => {
package/qa_coverage.py CHANGED
@@ -62,6 +62,13 @@ def strings_from_pptx(path):
62
62
  paras.append("".join(runs))
63
63
  if paras:
64
64
  out.append((i, " ".join(paras)))
65
+ # Chart series names and category labels live in ppt/charts, not on the
66
+ # slide. A rebuilt chart that drops a series name must still fail here.
67
+ for n in sorted(x for x in z.namelist() if re.fullmatch(r"ppt/charts/chart\d+\.xml", x)):
68
+ vals = re.findall(r"<c:v>([^<]*)</c:v>|<a:t>([^<]*)</a:t>", z.read(n).decode("utf8", "ignore"))
69
+ words = [a or b for a, b in vals if (a or b).strip() and not re.fullmatch(r"[-\d.,%]+", a or b)]
70
+ if words:
71
+ out.append((0, " ".join(words)))
65
72
  return out
66
73
 
67
74
 
@@ -23,7 +23,9 @@ z = zipfile.ZipFile(deck)
23
23
  names = sorted((n for n in z.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)),
24
24
  key=lambda n: int(re.findall(r"\d+", n)[0]))
25
25
  pres = z.read("ppt/presentation.xml").decode()
26
- cx, cy = (int(v) for v in re.search(r'<p:sldSz cx="(\d+)" cy="(\d+)"', pres).groups())
26
+ # attribute order varies by writer — the Kearney deck writes cy before cx
27
+ tag = re.search(r"<p:sldSz[^>]*>", pres).group(0)
28
+ cx, cy = (int(re.search(rf'{k}="(\d+)"', tag).group(1)) for k in ("cx", "cy"))
27
29
  canvas_ok = abs(cx / 914400 - 10) < 0.01 and abs(cy / 914400 - 5.625) < 0.01
28
30
 
29
31
  bad = 0
package/wand.js CHANGED
@@ -10,11 +10,14 @@
10
10
  * node wand.js doctor what works here, and what does not
11
11
  * node wand.js rebuild source.pptx phases 0-3: extract, then the worksheet
12
12
  * node wand.js layouts [name] the catalog, with every field
13
- node wand.js glyphs [search] the named icon set
13
+ * node wand.js glyphs [search] the named icon set
14
14
  * node wand.js validate work/deck.json fields and counts, before the build
15
15
  * node wand.js preview work/deck.json browser preview at the 10in grid
16
16
  * node wand.js ship work/deck.json build .pptx and .pdf, then every gate
17
- node wand.js spec deck.pptx read back the spec a built deck carries
17
+ * node wand.js show deck.pptx [n] one line per slide, or slide n's fields
18
+ * node wand.js edit deck.pptx --set 4.titleText="..." patch a kit-built deck, rebuild, gate
19
+ * node wand.js audit any.pptx every spec-free gate, incl. brand, on any deck
20
+ * node wand.js spec deck.pptx read back the spec a built deck carries
18
21
  *
19
22
  * `rebuild` and `ship` are the two that matter. Everything else is a stage of
20
23
  * one of them, exposed so a failure can be re-run on its own.
@@ -254,6 +257,100 @@ function ship(specPath, outArg) {
254
257
  return gates.some(([, ok]) => ok === false) ? 1 : 0;
255
258
  }
256
259
 
260
+ /* --------------------------------------------------------------- show/edit */
261
+
262
+ /**
263
+ * Why these exist: a salesperson's edits are small — a date, a name, a title —
264
+ * and the old path cost two full copies of the spec in the context (read it,
265
+ * write it back) plus a rebuild. A 40 KB spec is ~10K tokens each way.
266
+ * `show` prints one line per slide, or one slide's fields; `edit` patches the
267
+ * spec the deck carries inside itself and ships. An edit costs a command line.
268
+ */
269
+ function specOf(file) {
270
+ const f = path.resolve(file);
271
+ if (f.endsWith(".json")) return JSON.parse(fs.readFileSync(f, "utf8"));
272
+ const z = readZip(f);
273
+ if (!z.has("wand/spec.json")) die(`${rel(f)} carries no spec — it was not built by this kit.\n` +
274
+ "Edit it directly with the pptx tools, then run `node wand.js audit` on it (SKILL.md, \"Edit a deck they attached\").");
275
+ return JSON.parse(z.text("wand/spec.json"));
276
+ }
277
+
278
+ function oneLine(sl) {
279
+ // titleText arrays are runs of one line; titleLines are separate lines
280
+ const t = sl.titleText || sl.titleLines || sl.line1 || "";
281
+ const title = (Array.isArray(t) ? t.join(sl.titleLines && !sl.titleText ? " " : "") : String(t)).replace(/\s+/g, " ");
282
+ const bits = [sl.backup ? "BACKUP" : null, sl.notes ? "notes" : null, sl.dark === false ? "light" : null].filter(Boolean);
283
+ return `${sl.layout.padEnd(15)} ${title.slice(0, 64)}${bits.length ? " [" + bits.join(", ") + "]" : ""}`;
284
+ }
285
+
286
+ function show(file, which) {
287
+ const spec = specOf(file);
288
+ if (which == null) {
289
+ console.log(`${spec.deck || "(untitled)"} — ${spec.slides.length} slides`);
290
+ spec.slides.forEach((sl, i) => console.log(` ${String(i + 1).padStart(2)} ${oneLine(sl)}`));
291
+ console.log("\nnode wand.js show <deck> <n> for one slide's fields");
292
+ return 0;
293
+ }
294
+ const sl = spec.slides[Number(which) - 1];
295
+ if (!sl) die(`no slide ${which} — the deck has ${spec.slides.length}`);
296
+ console.log(JSON.stringify(sl, null, 1));
297
+ return 0;
298
+ }
299
+
300
+ /** "4.items.2.body" sets slide 4, item 2, body. 1-based, the way the room counts. */
301
+ function setPath(spec, dotted, value) {
302
+ const [n, ...keys] = dotted.split(".");
303
+ let node = spec.slides[Number(n) - 1];
304
+ if (!node) die(`--set ${dotted}: no slide ${n}`);
305
+ if (!keys.length) die(`--set ${dotted}: name a field, e.g. ${n}.titleText`);
306
+ for (let i = 0; i < keys.length - 1; i++) {
307
+ const k = /^\d+$/.test(keys[i]) ? Number(keys[i]) - 1 : keys[i];
308
+ if (node[k] == null) node[k] = /^\d+$/.test(keys[i + 1]) ? [] : {};
309
+ node = node[k];
310
+ }
311
+ const last = keys[keys.length - 1];
312
+ node[/^\d+$/.test(last) ? Number(last) - 1 : last] = value;
313
+ }
314
+
315
+ /** Deck_v2.pptx -> Deck_v3.pptx; Deck.pptx -> Deck_v2.pptx. Never overwrite the input. */
316
+ function nextVersion(file) {
317
+ const ext = path.extname(file), base = file.slice(0, -ext.length);
318
+ const m = /_v(\d+)$/.exec(base);
319
+ return m ? `${base.slice(0, m.index)}_v${Number(m[1]) + 1}${ext}` : `${base}_v2${ext}`;
320
+ }
321
+
322
+ function edit(file, args) {
323
+ const spec = specOf(file);
324
+ const changed = [];
325
+ for (let i = 0; i < args.length; i++) {
326
+ const a = args[i];
327
+ if (a === "--set") {
328
+ const arg = args[++i] || "", eq = arg.indexOf("=");
329
+ if (eq < 1) die(`--set wants N.path=value, got "${arg}"`);
330
+ const key = arg.slice(0, eq), raw = arg.slice(eq + 1);
331
+ let val; try { val = JSON.parse(raw); } catch { val = raw; } // arrays, numbers, booleans parse; prose stays prose
332
+ setPath(spec, key, val); changed.push(`set ${key}`);
333
+ } else if (a === "--del") {
334
+ const n = Number(args[++i]); spec.slides.splice(n - 1, 1); changed.push(`deleted S${n}`);
335
+ } else if (a === "--move") {
336
+ const from = Number(args[++i]), to = Number(args[++i]);
337
+ const [sl] = spec.slides.splice(from - 1, 1); spec.slides.splice(to - 1, 0, sl); changed.push(`moved S${from} to ${to}`);
338
+ } else if (a === "--add") {
339
+ const at = Number(args[++i]); spec.slides.splice(at - 1, 0, JSON.parse(args[++i])); changed.push(`added S${at}`);
340
+ }
341
+ }
342
+ if (!changed.length) die("nothing to do — pass --set, --del, --move or --add");
343
+ console.log(changed.map((c) => " " + c).join("\n"));
344
+ const work = path.join(path.dirname(path.resolve(file)), "work");
345
+ fs.mkdirSync(work, { recursive: true });
346
+ const specPath = path.join(work, "deck.json");
347
+ fs.writeFileSync(specPath, JSON.stringify(spec, null, 2));
348
+ if (args.includes("--dry")) { show(specPath); return validate(specPath) ? 1 : 0; }
349
+ const oi = args.indexOf("-o");
350
+ const out = oi >= 0 ? path.resolve(args[oi + 1]) : nextVersion(path.resolve(file));
351
+ return ship(specPath, out);
352
+ }
353
+
257
354
  /**
258
355
  * Every gate that does not need a spec, on any .pptx.
259
356
  *
@@ -359,6 +456,13 @@ function inspect(file) {
359
456
  errs.push('fonts embedded but saveSubsetFonts is not "0" - digits garble in exported PDFs');
360
457
  }
361
458
  if (!fonts.length) errs.push("no embedded fonts - this renders as Arial on the client's machine");
459
+ // pptxgenjs writes its default outline — 1pt #333333 — whenever a shape asks
460
+ // for `line: { width: 0 }`. No Wand token is 333333, so this is always a leak.
461
+ let leaks = 0;
462
+ for (const name of z.names.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))) {
463
+ leaks += (z.text(name).match(/<a:ln w="12700"><a:solidFill><a:srgbClr val="333333"\/>/g) || []).length;
464
+ }
465
+ if (leaks) errs.push(`${leaks} shape(s) carry pptxgenjs's default 1pt #333333 outline — a layout used line:{width:0}; use line:{type:"none"}`);
362
466
  errs.forEach((e) => console.log(` ERROR ${e}`));
363
467
  console.log(`file : ${slides.length} slides, ${media.length} media, ${fonts.length} font parts / ` +
364
468
  `${families} families` + (errs.length ? ` - ${errs.length} problem(s) above` : " - clean"));
@@ -486,6 +590,16 @@ to.
486
590
  process.exit(0);
487
591
  }
488
592
 
593
+ case "show": {
594
+ if (!positional[0]) die("usage: node wand.js show <deck.pptx|deck.json> [slide]");
595
+ process.exit(show(positional[0], positional[1]));
596
+ }
597
+
598
+ case "edit": {
599
+ if (!positional[0]) die("usage: node wand.js edit <deck.pptx> --set N.path=value [--del N] [--move N M] [--add N '<json>'] [-o out.pptx] [--dry]");
600
+ process.exit(edit(positional[0], rest));
601
+ }
602
+
489
603
  case "inspect":
490
604
  if (!positional[0]) die("usage: node wand.js inspect <deck.pptx>");
491
605
  process.exit(inspect(path.resolve(positional[0])) ? 1 : 0);