wand-decks-kit 0.8.7 → 0.9.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.
package/lib/schema.js CHANGED
@@ -36,7 +36,8 @@ const L = {
36
36
  bleed: true,
37
37
  fields: {
38
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" })),
39
+ titleLines: s("string[]", req({ max: 3, note: "one entry per line; auto-shrinks 39→30pt" })),
40
+ presentedTo: s("string[]", { max: 3, note: "the audience, bottom right — one name and title per entry" }),
40
41
  },
41
42
  },
42
43
  divider: {
@@ -50,6 +51,28 @@ const L = {
50
51
  fields: { eyebrow: str(), line1: str(req()), line2: str(), body: str(), url: str() },
51
52
  },
52
53
 
54
+ /* ----------------------------------------------------- guideline (0.9) */
55
+ agenda: {
56
+ 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() } })) },
58
+ },
59
+ team: {
60
+ use: "Who's in the room — 2–4 people, each with a headshot slot the user fills",
61
+ fields: { items: s("object[]", req({ min: 2, max: 4, of: { name: str(req()), role: str(), initials: str() } })) },
62
+ },
63
+ fourCards: {
64
+ use: "Four titled cards under a lede — demo objectives, pillars, principles",
65
+ fields: { items: s("object[]", req({ min: 3, max: 4, of: { title: str(req()), body: str(req()) } })) },
66
+ },
67
+ deliverColumns: {
68
+ use: "Three flat columns with an accent bar — how we deliver, phases with an owner line. Light by default",
69
+ fields: { columns: s("object[]", req({ min: 2, max: 3, of: { heading: str(req()), lead: str(), body: str(req()) } })) },
70
+ },
71
+ coDelivery: {
72
+ 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()) } })) },
74
+ },
75
+
53
76
  /* --------------------------------------------------------------- content */
54
77
  threeUp: {
55
78
  use: "3 comparable things. `arrows: true` for a progression",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wand-decks-kit",
3
- "version": "0.8.7",
3
+ "version": "0.9.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": {
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ brandlint.py — is this deck on the guideline?
4
+
5
+ python3 tools/brandlint.py deck.pptx palette.json
6
+
7
+ For a deck the kit did not build — one edited directly, or a client's file — the
8
+ fit gate cannot run, but the guideline still can: is every text run in Geist, is
9
+ every colour a token, is the canvas 10 x 5.625, does every content slide carry
10
+ the tag and the page footer. Reports per slide and exits 1 on any miss, so an
11
+ edit-only job ends with a list of inconsistencies to put to the user, not a
12
+ silent hand-back.
13
+
14
+ Only runs that carry text count. PowerPoint leaves Calibri on empty end-of-
15
+ paragraph runs; flagging those would make every deck fail.
16
+ """
17
+ import json, os, re, sys, zipfile, collections
18
+
19
+ deck, palette = sys.argv[1], json.load(open(sys.argv[2]))
20
+ tokens = {v.upper() for v in palette.values()}
21
+ GEIST = re.compile(r"^Geist( |$)")
22
+ z = zipfile.ZipFile(deck)
23
+ names = sorted((n for n in z.namelist() if re.fullmatch(r"ppt/slides/slide\d+\.xml", n)),
24
+ key=lambda n: int(re.findall(r"\d+", n)[0]))
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())
27
+ canvas_ok = abs(cx / 914400 - 10) < 0.01 and abs(cy / 914400 - 5.625) < 0.01
28
+
29
+ bad = 0
30
+ print(f"canvas : {cx/914400:.3f} x {cy/914400:.3f} in — {'ok' if canvas_ok else 'NOT the 10 x 5.625 grid'}")
31
+ bad += not canvas_ok
32
+ font_hits = collections.Counter(); colour_hits = collections.Counter(); chrome_miss = []
33
+ for n in names:
34
+ i = int(re.findall(r"\d+", n)[0])
35
+ s = z.read(n).decode("utf8", "ignore")
36
+ # runs with text: <a:r><a:rPr ...>…</a:rPr><a:t>text</a:t></a:r>
37
+ for m in re.finditer(r"<a:r>(.*?)</a:r>", s, re.S):
38
+ run = m.group(1)
39
+ t = re.search(r"<a:t>([^<]*)</a:t>", run)
40
+ if not t or not t.group(1).strip():
41
+ continue
42
+ face = re.search(r'<a:latin typeface="([^"]+)"', run)
43
+ if face and not GEIST.match(face.group(1)):
44
+ font_hits[(i, face.group(1))] += 1
45
+ for c in re.findall(r'<a:srgbClr val="([0-9A-Fa-f]{6})"', run):
46
+ if c.upper() not in tokens:
47
+ colour_hits[(i, c.upper())] += 1
48
+ # Content slides carry the footer; cover, dividers and the closing drop it by
49
+ # design. Those three are the first slide, the last, and any slide with fewer
50
+ # than seven text frames (a divider is tag, eyebrow, title, body).
51
+ frames = [t for t in re.findall(r"<a:t>([^<]*)</a:t>", s) if t.strip()]
52
+ if i not in (1, len(names)) and len(frames) >= 7 and "WAND" not in " ".join(frames).upper():
53
+ chrome_miss.append(i)
54
+
55
+ if font_hits:
56
+ bad += 1
57
+ by = collections.defaultdict(list)
58
+ for (i, f), c in sorted(font_hits.items()): by[i].append(f"{f} x{c}")
59
+ print("fonts : NOT Geist on " + ", ".join(f"S{i:02} ({'; '.join(v)})" for i, v in by.items()))
60
+ else:
61
+ print("fonts : every text run is Geist")
62
+ if colour_hits:
63
+ top = colour_hits.most_common(8)
64
+ print("colours : " + str(len(colour_hits)) + " off-palette use(s) — " +
65
+ ", ".join(f"S{i:02} {c} x{n}" for (i, c), n in top))
66
+ bad += 1
67
+ else:
68
+ print("colours : every text colour is a token")
69
+ if chrome_miss:
70
+ print("chrome : no WAND footer/tag text on " + ", ".join(f"S{i:02}" for i in chrome_miss))
71
+ bad += 1
72
+ else:
73
+ print("chrome : footer present on every content slide")
74
+ sys.exit(1 if bad else 0)
package/wand.js CHANGED
@@ -304,13 +304,23 @@ function audit(file, dump) {
304
304
  console.log(soffice ? "pdftoppm absent — no renders" : "no LibreOffice — no renders; open the deck and look at every slide");
305
305
  }
306
306
 
307
- console.log(rule("3 coverage"));
307
+ console.log(rule("3 brand"));
308
+ // The guideline, as a gate: fonts, colours, canvas, chrome. This is what an
309
+ // edit-only job reports back as "design inconsistencies" before touching
310
+ // anything the user did not ask for.
311
+ const pal = path.join(require("os").tmpdir(), `wand-palette-${process.pid}.json`);
312
+ fs.writeFileSync(pal, JSON.stringify(require("./wand_kit").T));
313
+ const brand = runPython([path.join(KIT, "tools", "brandlint.py"), file, pal]);
314
+ fs.rmSync(pal, { force: true });
315
+
316
+ console.log(rule("4 coverage"));
308
317
  let cov = null;
309
318
  if (dump && fs.existsSync(dump)) cov = runPython([path.join(KIT, "qa_coverage.py"), dump, file]);
310
319
  else console.log("no dump given — skipped. Pass --dump work/visible_dump.txt from `rebuild` to check\n" +
311
320
  "every source string survived the edit.");
312
321
 
313
- const gates = [["file", fileErrs === 0], ["pdf text", garbled === null ? null : garbled === 0],
322
+ const gates = [["file", fileErrs === 0], ["brand", brand === 0],
323
+ ["pdf text", garbled === null ? null : garbled === 0],
314
324
  ["renders", shots > 0 ? true : null], ["coverage", cov === null ? null : cov === 0]];
315
325
  console.log(rule("gates"));
316
326
  for (const [n, ok] of gates) console.log(` ${ok === null ? "skip" : ok ? " ok " : "FAIL"} ${n}`);
package/wand_kit.js CHANGED
@@ -25,23 +25,27 @@ function autoSize(text, w, h, opts) {
25
25
  /* ------------------------------------------------------------------ tokens */
26
26
 
27
27
  const T = {
28
- accent: "8B7CF6",
29
- accentDeep: "6C5CE7",
30
- accentSoft: "A99CF8",
31
- accentPale: "C4B5F4",
28
+ // Measured from the guideline deck (Vivicta_Session_Deck, 2026-09-23): the
29
+ // counts are how many shape fills / text runs use each value across its 16
30
+ // slides. A token here is a guideline value, not a taste.
31
+ accent: "8B7CF6", // tags, accent-italic, rules, pills (fills 18, text 14)
32
+ accentDeep: "6C5CE7", // accent text on light surfaces
33
+ accentSoft: "9481FF", // accent text on dark: eyebrows, links, pills (text 22)
34
+ accentPale: "DCD6F5", // quote marks, soft labels on dark (text 12)
32
35
 
33
- bgDark: "0F0C26",
34
- inkDark: "F3F1FA",
36
+ bgDark: "240F48", // flat dark ground — the guideline paints no art on interior slides
37
+ inkDark: "FFFFFF", // titles (text 101)
35
38
  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",
39
+ bodyDark: "F5F5F5", // body (text 54)
40
+ mutedDark: "BBBBBB", // captions, footer (text 32)
41
+ bodyDarkAlt: "C6BFE4", // lists inside cards (text 22)
42
+ cardStroke: "5B4BB0", // 1pt card stroke (lines 16)
43
+ cardSolid: "2A1F5C", // opaque card (fills 23)
44
+ cardMuted: "1B1738", // de-emphasised card (fills 16)
45
+ cardMutedStroke: "3A2E63",
46
+ cardEmphFlat: "251C63", // emphasis surface (fills 8)
47
+ band: "240F48",
48
+ footerDark: "BBBBBB",
45
49
 
46
50
  bgLight: "F8F6FB",
47
51
  inkLight: "1D1A33",
@@ -93,8 +97,9 @@ const theme = (dark) => dark
93
97
 
94
98
  function background(slide, dark) {
95
99
  if (dark) {
100
+ // The guideline's interior dark slides are a flat 240F48 — the gradient
101
+ // art belongs to the cover and closing only.
96
102
  slide.background = { color: T.bgDark };
97
- slide.addImage({ path: `${ASSETS}/bg-overlay-dark.png`, x: 0, y: 0, w: 10, h: 5.625 });
98
103
  } else {
99
104
  slide.background = { color: T.bgLight };
100
105
  }
@@ -104,27 +109,28 @@ function background(slide, dark) {
104
109
  function chrome(slide, { tag, dark = true }) {
105
110
  if (tag) {
106
111
  slide.addText(tag.toUpperCase(), {
107
- x: 0.35, y: 0.24, w: 6.75, h: 0.17, margin: 0, isTextBox: true,
112
+ x: 0.42, y: 0.24, w: 6.75, h: 0.17, margin: 0, isTextBox: true,
108
113
  fontFace: F.reg, fontSize: 8, bold: true, color: T.accent, valign: "middle",
109
114
  });
110
115
  }
111
116
  slide.addImage({
112
117
  path: `${ASSETS}/${dark ? "mark-white.png" : "mark-dark.png"}`,
113
- x: 9.52, y: 0.24, w: 0.23, h: 0.19,
118
+ x: 9.40, y: 0.26, w: 0.22, h: 0.18,
114
119
  });
115
120
  }
116
121
 
117
122
  /** Footer band. `n` and `total` are literal — never a field. */
118
123
  function footer(slide, { deck, n, total, dark = true, confidential = "STRICTLY CONFIDENTIAL" }) {
119
124
  const c = dark ? T.footerDark : T.footerLight;
125
+ // guideline: a 0.34" band at y 5.29, 7pt, "WAND · CONFIDENTIAL" left, "03/13" right
120
126
  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",
127
+ x: 0.42, y: 5.29, w: 4.58, h: 0.34, margin: 0, isTextBox: true,
128
+ fontFace: F.reg, fontSize: 7, color: c, valign: "middle",
123
129
  });
124
130
  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",
131
+ slide.addText(`${confidential} · ${pad(n)}/${pad(total)}`, {
132
+ x: 5.23, y: 5.29, w: 4.38, h: 0.34, margin: 0, isTextBox: true,
133
+ fontFace: F.reg, fontSize: 7, color: c, align: "right", valign: "middle",
128
134
  });
129
135
  }
130
136
 
@@ -216,9 +222,17 @@ function kicker(slide, text, { dark = true, y = 4.82 } = {}) {
216
222
  function card(slide, { x, y, w, h, dark = true, variant = "glass" }) {
217
223
  const th = theme(dark);
218
224
  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 }; }
225
+ if (dark) {
226
+ if (variant === "solid") { fill = { color: T.cardSolid }; line = { color: T.cardMutedStroke, width: 1 }; }
227
+ if (variant === "muted") { fill = { color: T.cardMuted }; line = { color: T.cardMutedStroke, width: 1, dashType: "dash" }; }
228
+ if (variant === "emph") { fill = { color: T.cardEmphFlat }; line = { color: T.cardMutedStroke, width: 1 }; }
229
+ } else {
230
+ // The dark variants are dark purples; painted on a light slide they hide
231
+ // the light theme's ink. A guideline agenda rendered as four black boxes.
232
+ if (variant === "solid") { fill = { color: T.cardLight }; line = { color: T.divider, width: 1 }; }
233
+ if (variant === "muted") { fill = { color: T.surface }; line = { color: T.divider, width: 1, dashType: "dash" }; }
234
+ if (variant === "emph") { fill = { color: T.surface }; line = { color: T.accentPale, width: 1 }; }
235
+ }
222
236
  slide.addShape("roundRect", {
223
237
  x, y, w, h, fill: { ...fill }, line: { ...line }, rectRadius: radius(w, h),
224
238
  });
@@ -267,37 +281,43 @@ function pill(slide, { x, y, w, h = 0.34, text, variant = "outline", dark = true
267
281
  /* ------------------------------------------------------------------ layouts */
268
282
 
269
283
  /** L1 cover — dark only, full bleed, no footer. */
270
- function cover(pres, { eyebrow, titleLines, body, kickerPlain, kickerBold, tag }) {
284
+ function cover(pres, { eyebrow, titleLines, body, kickerPlain, kickerBold, tag, presentedTo }) {
271
285
  const s = pres.addSlide();
272
286
  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 });
287
+ // The guideline cover is one full-bleed composition with the orb baked in.
288
+ s.addImage({ path: `${ASSETS}/cover-art.png`, x: 0, y: 0, w: 10, h: 5.625 });
275
289
  if (tag) chrome(s, { tag, dark: true });
276
290
  if (eyebrow) 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",
291
+ x: 0.42, y: 1.36, w: 6.0, h: 0.3, margin: 0, isTextBox: true,
292
+ fontFace: F.med, fontSize: 11, color: T.accentSoft, valign: "middle",
279
293
  });
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 });
294
+ const tSize = autoSize(titleLines.join("\n"), 6.29, 1.23,
295
+ { family: F.xbold, fontSize: 39, min: 30, lineSpacingMultiple: 1.05 });
284
296
  s.addText(titleLines.join("\n"), {
285
- x: 0.39, y: 1.58, w: 5.55, h: 1.55, margin: 0, isTextBox: true,
297
+ x: 0.42, y: 1.79, w: 6.29, h: 1.23, margin: 0, isTextBox: true,
286
298
  fontFace: F.xbold, fontSize: tSize, color: T.white, valign: "top", lineSpacingMultiple: 1.05,
287
299
  });
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,
300
+ if (body) s.addText(body, {
301
+ x: 0.42, y: 3.15, w: 5.56, h: 0.6, margin: 0, isTextBox: true,
302
+ fontFace: F.reg, fontSize: 12.5, color: T.bodyDark, valign: "top", lineSpacingMultiple: 1.12,
291
303
  });
292
- s.addShape("rect", { x: 0.38, y: 3.95, w: 0.60, h: 0.04, fill: { color: T.accent }, line: { width: 0 } });
293
- // Concatenating an absent kicker used to paint the word "undefined" on the
294
- // cover — visible only in a render, past every machine gate.
295
304
  const kickerRuns = [
296
- kickerPlain && { text: kickerPlain + " ", options: { fontFace: F.reg, fontSize: 10, color: T.inkDark } },
305
+ kickerPlain && { text: kickerPlain + " ", options: { fontFace: F.reg, fontSize: 10, color: T.bodyDark } },
297
306
  kickerBold && { text: kickerBold, options: { fontFace: F.reg, fontSize: 10, bold: true, color: T.white } },
298
307
  ].filter(Boolean);
299
308
  if (kickerRuns.length) s.addText(kickerRuns,
300
- { x: 0.39, y: 4.03, w: 6.0, h: 0.4, margin: 0, isTextBox: true, valign: "middle" });
309
+ { x: 0.42, y: 4.52, w: 5.5, h: 0.4, margin: 0, isTextBox: true, valign: "middle" });
310
+ // "PRESENTED TO" block, bottom right — the guideline's audience line.
311
+ if (presentedTo && presentedTo.length) {
312
+ s.addText("PRESENTED TO", {
313
+ x: 6.67, y: 4.52, w: 3.21, h: 0.22, margin: 0, isTextBox: true,
314
+ fontFace: F.med, fontSize: 8, color: T.accentSoft, valign: "middle",
315
+ });
316
+ s.addText(presentedTo.join("\n"), {
317
+ x: 6.67, y: 4.74, w: 3.21, h: 0.66, margin: 0, isTextBox: true,
318
+ fontFace: F.reg, fontSize: 9.5, color: T.white, valign: "top", lineSpacingMultiple: 1.2,
319
+ });
320
+ }
301
321
  return s;
302
322
  }
303
323
 
@@ -331,23 +351,30 @@ function divider(pres, { tag, eyebrow, titleText, body }) {
331
351
  function closing(pres, { eyebrow, line1, line2, body, url }) {
332
352
  const s = pres.addSlide();
333
353
  s.background = { color: T.bgDark };
334
- s.addImage({ path: `${ASSETS}/bg-statement-dark.png`, x: 0, y: 0, w: 10, h: 5.625 });
335
- s.addImage({ path: `${ASSETS}/wordmark-white.png`, x: 4.60, y: 0.98, w: 0.80, h: 0.22 });
354
+ s.addImage({ path: `${ASSETS}/closing-art.png`, x: 0, y: 0, w: 10, h: 5.625 });
355
+ s.addImage({ path: `${ASSETS}/mark-white.png`, x: 9.40, y: 0.24, w: 0.22, h: 0.19 });
356
+ s.addImage({ path: `${ASSETS}/orb-glow.png`, x: 3.98, y: 0.69, w: 2.03, h: 2.03 });
357
+ // The orb owns the top; everything reads below it. A long line shrinks
358
+ // rather than wrapping into the eyebrow — the guideline's "Thank You" is
359
+ // 33pt, but a real ask is usually a sentence.
336
360
  if (eyebrow) s.addText(eyebrow.toUpperCase(), {
337
- x: 0.55, y: 1.85, w: 8.9, h: 0.3, margin: 0, isTextBox: true,
338
- fontFace: F.med, fontSize: 12, color: T.accentSoft, align: "center", valign: "middle",
361
+ x: 1.16, y: 2.82, w: 7.67, h: 0.24, margin: 0, isTextBox: true,
362
+ fontFace: F.med, fontSize: 10, color: T.accentSoft, align: "center", valign: "middle",
339
363
  });
364
+ const joined = (line1 || "") + (line2 ? " " + line2 : "");
365
+ const size = autoSize(joined, 7.67, 0.72, { family: F.semi, fontSize: 33, min: 22, lineSpacingMultiple: 1.05 });
340
366
  s.addText([
341
- line1 && { text: line1 + (line2 ? "\n" : ""), options: { fontFace: F.reg, fontSize: 24, bold: true, color: T.white } },
342
- line2 && { text: line2, options: { fontFace: F.reg, fontSize: 24, bold: true, italic: true, color: T.accentSoft } },
343
- ].filter(Boolean), { x: 0.55, y: 2.19, w: 8.9, h: 1.0, margin: 0, isTextBox: true, align: "center", valign: "top", lineSpacingMultiple: 1.15 });
367
+ line1 && { text: line1 + (line2 ? " " : ""), options: { fontFace: F.semi, fontSize: size, color: T.white } },
368
+ line2 && { text: line2, options: { fontFace: F.semi, fontSize: size, italic: true, color: T.accentSoft } },
369
+ ].filter(Boolean), { x: 1.16, y: 3.08, w: 7.67, h: 0.72, margin: 0, isTextBox: true, align: "center", valign: "middle", lineSpacingMultiple: 1.05 });
344
370
  if (body) s.addText(body, {
345
- x: 1.5, y: 3.23, w: 7.0, h: 0.7, margin: 0, isTextBox: true,
346
- fontFace: F.reg, fontSize: 12.5, color: T.bodyDark, align: "center", valign: "top", lineSpacingMultiple: 1.25,
371
+ x: 1.16, y: 3.90, w: 7.67, h: 0.62, margin: 0, isTextBox: true,
372
+ fontFace: F.reg, fontSize: autoSize(body, 7.67, 0.62, { family: F.reg, fontSize: 16, min: 11, lineSpacingMultiple: 1.2 }),
373
+ color: T.bodyDark, align: "center", valign: "top", lineSpacingMultiple: 1.2,
347
374
  });
348
375
  if (url) s.addText(url, {
349
- x: 1.5, y: 4.30, w: 7.0, h: 0.3, margin: 0, isTextBox: true,
350
- fontFace: F.reg, fontSize: 10, bold: true, color: T.accentSoft, align: "center", valign: "middle",
376
+ x: 1.16, y: 4.62, w: 7.67, h: 0.24, margin: 0, isTextBox: true,
377
+ fontFace: F.reg, fontSize: 10, color: T.mutedDark, align: "center", valign: "middle",
351
378
  });
352
379
  return s;
353
380
  }
@@ -998,12 +1025,170 @@ function productMap(pres, { tag, titleText, subtitleText, bandRow, cards, split,
998
1025
  /* ------------------------------------------------------------------ export */
999
1026
 
1000
1027
 
1028
+
1029
+ /* ------------------------------------------------- guideline layouts (0.9) */
1030
+ /* Measured from Vivicta_Session_Deck: the slides a sales session adds to the
1031
+ standard deck — agenda, team, demo objectives, delivery columns, co-delivery. */
1032
+
1033
+ /** Agenda — 2–5 numbered cards in a row (S03). items: [{ title, body }] */
1034
+ function agenda(pres, { tag, titleText, subtitleText, items, dark = true, deck, n, total }) {
1035
+ const s = pres.addSlide();
1036
+ background(s, dark); chrome(s, { tag, dark });
1037
+ const th = theme(dark);
1038
+ const top = contentTop(titleText, subtitleText);
1039
+ title(s, titleText, { dark, y: 0.62, h: 0.5 });
1040
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.14, h: 0.3 });
1041
+ const list = items.slice(0, 5);
1042
+ const gap = 0.22, w = (9.17 - gap * (list.length - 1)) / list.length, cy = 1.62, ch = 2.41;
1043
+ list.forEach((it, i) => {
1044
+ const cx = 0.42 + i * (w + gap);
1045
+ card(s, { x: cx, y: cy, w, h: ch, dark, variant: "solid" });
1046
+ s.addText(String(i + 1).padStart(2, "0"), {
1047
+ x: cx + 0.15, y: cy + 0.14, w: 1.0, h: 0.15, margin: 0, isTextBox: true,
1048
+ fontFace: F.semi, fontSize: 8, color: T.accentSoft, valign: "middle",
1049
+ });
1050
+ s.addText(it.title, {
1051
+ x: cx + 0.15, y: cy + 0.44, w: w - 0.3, h: 0.5, margin: 0, isTextBox: true,
1052
+ fontFace: F.semi, fontSize: 11, color: th.ink, valign: "top", lineSpacingMultiple: 1.15,
1053
+ });
1054
+ if (it.body) s.addText(it.body, {
1055
+ x: cx + 0.15, y: cy + 1.0, w: w - 0.3, h: ch - 1.15, margin: 0, isTextBox: true,
1056
+ fontFace: F.reg, fontSize: autoSize(it.body, w - 0.3, ch - 1.15, { family: F.reg, fontSize: 8.5, min: 7, lineSpacingMultiple: 1.3 }),
1057
+ color: th.body, valign: "top", lineSpacingMultiple: 1.3,
1058
+ });
1059
+ });
1060
+ if (deck) footer(s, { deck, n, total, dark });
1061
+ return s;
1062
+ }
1063
+
1064
+ /** Team — 2–4 people with a headshot slot (S02). items: [{ name, role, initials }] */
1065
+ function team(pres, { tag, titleText, items, dark = true, deck, n, total }) {
1066
+ const s = pres.addSlide();
1067
+ background(s, dark); chrome(s, { tag, dark });
1068
+ const th = theme(dark);
1069
+ title(s, titleText, { dark, y: 0.70, h: 0.5 });
1070
+ const list = items.slice(0, 4);
1071
+ const cw = 2.14, ch = 2.34, gap = 0.20;
1072
+ const x0 = (10 - (cw * list.length + gap * (list.length - 1))) / 2;
1073
+ list.forEach((it, i) => {
1074
+ const cx = x0 + i * (cw + gap), cy = 1.62;
1075
+ card(s, { x: cx, y: cy, w: cw, h: ch, dark, variant: "solid" });
1076
+ // headshot slot: a circle the user drops a picture onto; initials until then
1077
+ const d = 0.95;
1078
+ s.addShape("ellipse", { x: cx + (cw - d) / 2, y: cy + 0.22, w: d, h: d,
1079
+ fill: { color: dark ? T.cardMuted : T.surface }, line: { color: T.accentSoft, width: 1 } });
1080
+ s.addText(it.initials || it.name.split(/\s+/).map((p) => p[0]).join("").slice(0, 2).toUpperCase(), {
1081
+ x: cx + (cw - d) / 2, y: cy + 0.22, w: d, h: d, margin: 0, isTextBox: true,
1082
+ fontFace: F.semi, fontSize: 18, color: T.accentSoft, align: "center", valign: "middle",
1083
+ });
1084
+ s.addText(it.name, {
1085
+ x: cx + 0.15, y: cy + 1.32, w: cw - 0.3, h: 0.26, margin: 0, isTextBox: true,
1086
+ fontFace: F.semi, fontSize: 11, color: th.ink, align: "center", valign: "middle",
1087
+ });
1088
+ if (it.role) s.addText(it.role, {
1089
+ x: cx + 0.15, y: cy + 1.60, w: cw - 0.3, h: 0.5, margin: 0, isTextBox: true,
1090
+ fontFace: F.reg, fontSize: 8.5, color: T.accentSoft, align: "center", valign: "top", lineSpacingMultiple: 1.25,
1091
+ });
1092
+ });
1093
+ if (deck) footer(s, { deck, n, total, dark });
1094
+ return s;
1095
+ }
1096
+
1097
+ /** Four cards under a title and lede — demo objectives, pillars (S12). items: [{ title, body }] */
1098
+ function fourCards(pres, { tag, titleText, subtitleText, items, dark = true, deck, n, total }) {
1099
+ const s = pres.addSlide();
1100
+ background(s, dark); chrome(s, { tag, dark });
1101
+ const th = theme(dark);
1102
+ title(s, titleText, { dark, y: 0.62, h: 0.5 });
1103
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.14, h: 0.45 });
1104
+ const list = items.slice(0, 4);
1105
+ const gap = 0.20, w = (9.17 - gap * (list.length - 1)) / list.length, cy = 1.92, ch = 2.55;
1106
+ list.forEach((it, i) => {
1107
+ const cx = 0.42 + i * (w + gap);
1108
+ card(s, { x: cx, y: cy, w, h: ch, dark, variant: "solid" });
1109
+ s.addText(it.title, {
1110
+ x: cx + 0.17, y: cy + 0.22, w: w - 0.34, h: 0.5, margin: 0, isTextBox: true,
1111
+ fontFace: F.semi, fontSize: 11, color: th.ink, valign: "top", lineSpacingMultiple: 1.15,
1112
+ });
1113
+ s.addText(it.body, {
1114
+ x: cx + 0.17, y: cy + 0.82, w: w - 0.34, h: ch - 1.0, margin: 0, isTextBox: true,
1115
+ fontFace: F.reg, fontSize: autoSize(it.body, w - 0.34, ch - 1.0, { family: F.reg, fontSize: 8.5, min: 7, lineSpacingMultiple: 1.3 }),
1116
+ color: th.body, valign: "top", lineSpacingMultiple: 1.3,
1117
+ });
1118
+ });
1119
+ if (deck) footer(s, { deck, n, total, dark });
1120
+ return s;
1121
+ }
1122
+
1123
+ /** Three flat columns with an accent bar — "how we deliver" (S14). columns: [{ heading, lead, body }] */
1124
+ function deliverColumns(pres, { tag, titleText, subtitleText, columns, dark = false, deck, n, total }) {
1125
+ const s = pres.addSlide();
1126
+ background(s, dark); chrome(s, { tag, dark });
1127
+ const th = theme(dark);
1128
+ title(s, titleText, { dark, y: 0.58, h: 0.5 });
1129
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.14, h: 0.3 });
1130
+ const list = columns.slice(0, 3);
1131
+ const gap = 0.23, w = (9.17 - gap * (list.length - 1)) / list.length, cy = 1.62, ch = 3.02;
1132
+ list.forEach((c, i) => {
1133
+ const cx = 0.42 + i * (w + gap);
1134
+ s.addShape("rect", { x: cx, y: cy, w, h: ch, fill: { color: dark ? T.cardSolid : T.surface }, line: { width: 0 } });
1135
+ s.addShape("rect", { x: cx, y: cy, w, h: 0.04, fill: { color: T.accent }, line: { width: 0 } });
1136
+ s.addText(c.heading, {
1137
+ x: cx + 0.2, y: cy + 0.15, w: w - 0.4, h: 0.24, margin: 0, isTextBox: true,
1138
+ fontFace: F.semi, fontSize: 11, color: th.ink, valign: "middle",
1139
+ });
1140
+ if (c.lead) s.addText(c.lead, {
1141
+ x: cx + 0.2, y: cy + 0.40, w: w - 0.4, h: 0.32, margin: 0, isTextBox: true,
1142
+ fontFace: F.med, fontSize: 9.5, color: dark ? T.accentSoft : T.accentDeep, valign: "top", lineSpacingMultiple: 1.2,
1143
+ });
1144
+ s.addText(c.body, {
1145
+ x: cx + 0.2, y: cy + 0.80, w: w - 0.4, h: ch - 0.95, margin: 0, isTextBox: true,
1146
+ fontFace: F.reg, fontSize: autoSize(c.body, w - 0.4, ch - 0.95, { family: F.reg, fontSize: 8.5, min: 7, lineSpacingMultiple: 1.3 }),
1147
+ color: th.body, valign: "top", lineSpacingMultiple: 1.3,
1148
+ });
1149
+ });
1150
+ if (deck) footer(s, { deck, n, total, dark });
1151
+ return s;
1152
+ }
1153
+
1154
+ /** Numbered rows in two columns — a co-delivery model, principles (S13). items: [{ title, body }] up to 6 */
1155
+ function coDelivery(pres, { tag, titleText, subtitleText, items, dark = true, deck, n, total }) {
1156
+ const s = pres.addSlide();
1157
+ background(s, dark); chrome(s, { tag, dark });
1158
+ const th = theme(dark);
1159
+ title(s, titleText, { dark, y: 0.62, h: 0.5 });
1160
+ if (subtitleText) subtitle(s, subtitleText, { dark, y: 1.14, h: 0.3 });
1161
+ const list = items.slice(0, 6);
1162
+ const cw = 4.17, ch = 0.80, colX = [0.55, 5.04], rows = Math.ceil(list.length / 2), y0 = 1.65, gy = 0.26;
1163
+ list.forEach((it, i) => {
1164
+ const col = Math.floor(i / rows), row = i % rows;
1165
+ const cx = colX[col], cy = y0 + row * (ch + gy);
1166
+ card(s, { x: cx, y: cy, w: cw, h: ch, dark, variant: "solid" });
1167
+ s.addText(String(i + 1).padStart(2, "0"), {
1168
+ x: cx + 0.14, y: cy + 0.2, w: 0.35, h: 0.25, margin: 0, isTextBox: true,
1169
+ fontFace: F.semi, fontSize: 11, color: T.accentSoft, valign: "middle",
1170
+ });
1171
+ s.addText(it.title, {
1172
+ x: cx + 0.55, y: cy + 0.12, w: cw - 0.7, h: 0.19, margin: 0, isTextBox: true,
1173
+ fontFace: F.semi, fontSize: 10, color: th.ink, valign: "middle",
1174
+ });
1175
+ s.addText(it.body, {
1176
+ x: cx + 0.55, y: cy + 0.35, w: cw - 0.7, h: ch - 0.42, margin: 0, isTextBox: true,
1177
+ fontFace: F.reg, fontSize: autoSize(it.body, cw - 0.7, ch - 0.42, { family: F.reg, fontSize: 8, min: 6.5, lineSpacingMultiple: 1.2 }),
1178
+ color: th.body, valign: "top", lineSpacingMultiple: 1.2,
1179
+ });
1180
+ });
1181
+ if (deck) footer(s, { deck, n, total, dark });
1182
+ return s;
1183
+ }
1184
+
1001
1185
  module.exports = {
1002
- VERSION: "0.8.7",
1186
+ VERSION: "0.9.0",
1003
1187
  T, F, COLS, ASSETS, radius, theme, autoSize, measure: M,
1004
1188
  background, chrome, footer, title, subtitle, kicker, contentTop,
1005
1189
  card, iconTile, pill,
1006
1190
  cover, divider, closing,
1191
+ agenda, team, fourCards, deliverColumns, coDelivery,
1007
1192
  threeUp, fourUpIcon, sixUpIcon, rows, parity, statQuad, statGrid, band,
1008
1193
  colCards, featureStats, barRank, bridge, heroQuad, productMap,
1009
1194