slidemind 0.2.2 → 0.2.4

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.
@@ -113,7 +113,7 @@ const CURSOR_SHOW = "\x1b[?25h";
113
113
  // uniform.
114
114
  function startWordmarkAnimation() {
115
115
  const lines = wordmarkFrame(0);
116
- process.stdout.write("\n" + lines.join("\n") + "\n\n");
116
+ tui.out("\n" + lines.join("\n") + "\n\n");
117
117
  return () => {};
118
118
  }
119
119
 
@@ -125,6 +125,30 @@ export function isForce(flags) {
125
125
  return flags.includes("--force") || flags.includes("-F");
126
126
  }
127
127
 
128
+ // Is `name` an executable on the user's PATH? Used as the second half of
129
+ // harness detection: a coding agent's config dir (~/.claude, ~/.codex,
130
+ // ~/.config/opencode) is created by USE, not by install, so a freshly installed
131
+ // agent that has never been launched has no directory at all — its binary is
132
+ // the only evidence it exists. Resolved by walking PATH rather than shelling
133
+ // out to which/where, so it costs nothing and behaves the same on Windows.
134
+ export function binaryOnPath(name) {
135
+ const exts =
136
+ process.platform === "win32"
137
+ ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
138
+ : [""];
139
+ for (const dir of (process.env.PATH || "").split(path.delimiter)) {
140
+ if (!dir) continue;
141
+ for (const ext of exts) {
142
+ try {
143
+ if (fs.statSync(path.join(dir, name + ext)).isFile()) return true;
144
+ } catch {
145
+ /* not there / unreadable — keep looking */
146
+ }
147
+ }
148
+ }
149
+ return false;
150
+ }
151
+
128
152
  export function expandHome(p) {
129
153
  if (p.startsWith("~/")) return path.join(homeDir(), p.slice(2));
130
154
  return p;
@@ -234,6 +258,12 @@ let _startedAt = 0;
234
258
  let _stopWordmark = null;
235
259
 
236
260
  export const ui = {
261
+ // Suppress ALL decorative output. `slidemind setup --json` runs the real
262
+ // installers but owns stdout for its machine-readable payload, so the rail
263
+ // would corrupt the very thing the caller is parsing.
264
+ silence(value = true) {
265
+ tui.setSilent(value);
266
+ },
237
267
  // `wordmark: false` skips painting the wordmark — used when the caller
238
268
  // (e.g. the install.sh bootstrap) already printed it, so we only emit the
239
269
  // `v… · action` intro line and continue the rail.
@@ -276,12 +306,12 @@ export const ui = {
276
306
  // Manual emission so we can animate the title in place. Symbol
277
307
  // sits at col 1; " " padding lands the title at col 4.
278
308
  tui.bumpRow(1);
279
- process.stdout.write(tui.rail() + "\n");
309
+ tui.out(tui.rail() + "\n");
280
310
  tui.bumpRow(1);
281
- process.stdout.write(SYM.step + " ");
311
+ tui.out(SYM.step + " ");
282
312
  await tui.shimmer(title, { startCol: 4, restColor: (s) => s });
283
- if (sub) process.stdout.write(" " + brand.slate(tildify(sub)));
284
- process.stdout.write("\n");
313
+ if (sub) tui.out(" " + brand.slate(tildify(sub)));
314
+ tui.out("\n");
285
315
  },
286
316
  step({ status = "ok", label, value, note }) {
287
317
  const symbol =
@@ -328,15 +358,15 @@ export const ui = {
328
358
 
329
359
  const boxLine = (content) => {
330
360
  const pad = Math.max(0, inner - visibleLength(content));
331
- process.stdout.write(slate("│") + content + " ".repeat(pad) + slate("│") + "\n");
361
+ tui.out(slate("│") + content + " ".repeat(pad) + slate("│") + "\n");
332
362
  };
333
363
 
334
- process.stdout.write("\n");
364
+ tui.out("\n");
335
365
 
336
366
  // Top: ╭─ installed ────╮
337
367
  const labelText = " installed ";
338
368
  const fillerLen = Math.max(2, inner - 1 - labelText.length);
339
- process.stdout.write(
369
+ tui.out(
340
370
  slate("╭─") +
341
371
  dim(labelText) +
342
372
  slate("─".repeat(fillerLen)) +
@@ -359,10 +389,10 @@ export const ui = {
359
389
  boxLine(" " + dim(stats));
360
390
 
361
391
  // Bottom: ╰────────╯
362
- process.stdout.write(slate("╰" + "─".repeat(inner) + "╯") + "\n");
392
+ tui.out(slate("╰" + "─".repeat(inner) + "╯") + "\n");
363
393
 
364
- process.stdout.write("\n");
365
- process.stdout.write(`${SYM.success} ${brand.bold(action)}\n`);
394
+ tui.out("\n");
395
+ tui.out(`${SYM.success} ${brand.bold(action)}\n`);
366
396
 
367
397
  // Stop the wordmark animation last — that resets the scroll region
368
398
  // and shows the cursor. The wordmark itself stays painted at the
@@ -401,16 +431,16 @@ export const ui = {
401
431
  // Reserve two rows: rail spacer + the active spinner line. Both
402
432
  // count toward the gradient row counter.
403
433
  tui.bumpRow(1);
404
- process.stdout.write(tui.rail() + "\n");
434
+ tui.out(tui.rail() + "\n");
405
435
  tui.bumpRow(1);
406
- process.stdout.write(
436
+ tui.out(
407
437
  `${brand.techBlue40(SPINNER_FRAMES[0])} ${fmtStep({ label, value, note })}`,
408
438
  );
409
439
  let frame = 0;
410
440
  const timer = setInterval(() => {
411
441
  frame = (frame + 1) % SPINNER_FRAMES.length;
412
- process.stdout.write("\r\x1b[2K");
413
- process.stdout.write(
442
+ tui.out("\r\x1b[2K");
443
+ tui.out(
414
444
  `${brand.techBlue40(SPINNER_FRAMES[frame])} ${fmtStep({ label, value, note })}`,
415
445
  );
416
446
  }, 80);
@@ -418,15 +448,15 @@ export const ui = {
418
448
  const result = await fn();
419
449
  const time = Date.now() - start;
420
450
  clearInterval(timer);
421
- process.stdout.write("\r\x1b[2K");
422
- process.stdout.write(
451
+ tui.out("\r\x1b[2K");
452
+ tui.out(
423
453
  `${SYM.success} ${fmtStep({ label, value, note: finalNote ?? note, time })}\n`,
424
454
  );
425
455
  return result;
426
456
  } catch (err) {
427
457
  clearInterval(timer);
428
- process.stdout.write("\r\x1b[2K");
429
- process.stdout.write(
458
+ tui.out("\r\x1b[2K");
459
+ tui.out(
430
460
  `${SYM.error} ${fmtStep({ label, value, note: "failed" })}\n`,
431
461
  );
432
462
  throw err;
@@ -17,6 +17,7 @@
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
19
  import {
20
+ binaryOnPath,
20
21
  expandHome,
21
22
  installDir,
22
23
  isForce,
@@ -117,6 +118,21 @@ function removeLegacyArtifacts() {
117
118
  }
118
119
  }
119
120
 
121
+ // See claude.mjs `detect()`. opencode has no slidemind plugin channel, so the
122
+ // skills can only ever come from here.
123
+ export function detect() {
124
+ const root = opencodeRoot();
125
+ const configured = fs.existsSync(root);
126
+ const onPath = binaryOnPath("opencode");
127
+ return {
128
+ root,
129
+ configured,
130
+ onPath,
131
+ present: configured || onPath,
132
+ pluginInstalled: false,
133
+ };
134
+ }
135
+
120
136
  export async function install({ packageRoot, flags }) {
121
137
  const link = isLinkMode(flags);
122
138
  const force = isForce(flags);
@@ -23,6 +23,17 @@ const FADE_ROWS = 28; // rail finishes fading after this many emitted rows
23
23
 
24
24
  let _row = 0;
25
25
 
26
+ // Every terminal write in the installer UI funnels through here, so a caller
27
+ // whose stdout carries machine-readable output (`slidemind setup --json`) can
28
+ // silence the decorative rail without each writer knowing about it.
29
+ let _silent = false;
30
+ export function setSilent(value) {
31
+ _silent = Boolean(value);
32
+ }
33
+ export function out(text) {
34
+ if (!_silent) process.stdout.write(text);
35
+ }
36
+
26
37
  // Linear interpolate between #9999FF (techBlue40) and #6C7D8C (slate).
27
38
  // Top of the rail is bright tech-blue, fading to brand slate as the
28
39
  // install progresses — feels like the deck's tech-blue gradation card.
@@ -52,7 +63,7 @@ export function bumpRow(n = 1) {
52
63
 
53
64
  export function intro(text) {
54
65
  _row = 0;
55
- process.stdout.write("\n" + paint(_row, "┌") + " " + text + "\n");
66
+ out("\n" + paint(_row, "┌") + " " + text + "\n");
56
67
  }
57
68
 
58
69
  // Each message starts with its own gradient-coloured rail spacer, then
@@ -60,28 +71,28 @@ export function intro(text) {
60
71
  // (◆ ● ▲ ■ etc.); when omitted we fall back to a continuation rail.
61
72
  export function message(text, { symbol } = {}) {
62
73
  _row += 1;
63
- process.stdout.write(paint(_row, "│") + "\n");
74
+ out(paint(_row, "│") + "\n");
64
75
  _row += 1;
65
76
  const sym = symbol ?? paint(_row, "│");
66
- process.stdout.write(sym + " " + text + "\n");
77
+ out(sym + " " + text + "\n");
67
78
  }
68
79
 
69
80
  export function outro(text) {
70
81
  _row += 1;
71
- process.stdout.write(paint(_row, "│") + "\n");
82
+ out(paint(_row, "│") + "\n");
72
83
  _row += 1;
73
- process.stdout.write(paint(_row, "└") + " " + text + "\n\n");
84
+ out(paint(_row, "└") + " " + text + "\n\n");
74
85
  _row = 0;
75
86
  }
76
87
 
77
88
  // Reserve the next two rows for a spin step: emits the rail spacer,
78
89
  // returns the row index the spinner is written on. Callers should
79
- // `process.stdout.write(...)` the spinner content themselves (without a
90
+ // `out(...)` the spinner content themselves (without a
80
91
  // trailing `\n`) so they can overwrite it via `\r`. When the operation
81
92
  // completes they call `commitSpinRow()` after writing the final line.
82
93
  export function reserveSpinRows() {
83
94
  _row += 1;
84
- process.stdout.write(paint(_row, "│") + "\n");
95
+ out(paint(_row, "│") + "\n");
85
96
  _row += 1;
86
97
  return _row;
87
98
  }
@@ -96,7 +107,7 @@ const ANIMATE = !NO_COLOR && process.stdout.isTTY;
96
107
  export async function shimmer(text, { startCol = 1, restColor }) {
97
108
  const final = brand.bold(restColor(text));
98
109
  if (!ANIMATE) {
99
- process.stdout.write(final);
110
+ out(final);
100
111
  return;
101
112
  }
102
113
  const len = text.length;
@@ -117,9 +128,9 @@ export async function shimmer(text, { startCol = 1, restColor }) {
117
128
  else painter = (s) => brand.dim(brand.slate(s));
118
129
  line += brand.bold(painter(text[i]));
119
130
  }
120
- process.stdout.write(`\x1b[${startCol}G\x1b[K` + line);
131
+ out(`\x1b[${startCol}G\x1b[K` + line);
121
132
  if (f < frames) await new Promise((r) => setTimeout(r, frameMs));
122
133
  }
123
134
  // Final settle.
124
- process.stdout.write(`\x1b[${startCol}G\x1b[K` + final);
135
+ out(`\x1b[${startCol}G\x1b[K` + final);
125
136
  }
@@ -35,69 +35,11 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
35
35
  const locale = skill.locale || "en";
36
36
  const fieldsByNo = pack.fields[locale] ?? {};
37
37
  const repeatsByNo = pack.repeats[locale] ?? {};
38
-
39
- if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
40
- throw new Error(
41
- `template "${skill.templateKey}" not found in pack ${skill.skillDir} — check plan.md frontmatter and skill.json templates`,
42
- );
43
- }
44
-
45
- // ---- deck reading ---------------------------------------------------------
46
- const slidesDir = path.join(deckDir, "slides");
47
- const entries = fs.readdirSync(slidesDir)
48
- .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
49
- .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
50
- if (entries.length === 0) throw new Error(`no .jsx or .pptx slides in ${slidesDir}`);
51
-
52
- const problems = [];
53
- const warnings = [];
54
- const sequence = []; // { kind: "master", no, props, file } | { kind: "insert", file, buf }
55
-
56
- // In-deck freeform pairing: NN-name.jsx (create-slides source) renders to a
57
- // sibling NN-name.pptx artifact — when both exist, the .pptx carries the
58
- // slide and the .jsx is source only.
59
- const renderedStems = new Set(
60
- entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
61
- );
62
-
63
- for (const file of entries) {
64
- if (file.endsWith(".pptx")) {
65
- sequence.push({ kind: "insert", file, buf: fs.readFileSync(path.join(slidesDir, file)) });
66
- continue;
67
- }
68
- if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
69
- const src = fs.readFileSync(path.join(slidesDir, file), "utf8");
70
- let parsed;
71
- try {
72
- parsed = parseSlideFile(src, file);
73
- } catch (err) {
74
- problems.push(err.message);
75
- continue;
76
- }
77
- const layout = layoutNumber(parsed.component, locale, pack.components);
78
- if (!layout) {
79
- problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
80
- `rendered artifact next to it — 'slidemind build-pptx' renders slides/${file.replace(/\.jsx$/, "")}.pptx ` +
81
- `automatically when a paired add-in backend is reachable`);
82
- continue;
83
- }
84
- if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
85
- const no = String(layout.no);
86
-
87
- if ((pack.policy.freeform ?? []).includes(no)) {
88
- if (Object.keys(parsed.props).length > 0) {
89
- problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
90
- `Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
91
- continue;
92
- }
93
- warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
94
- }
95
-
96
- validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
97
- sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
98
- }
99
-
100
- checkIdentities(sequence, warnings, problems);
38
+ const { sequence, warnings, problems } = inspectFillTemplateDeck({
39
+ deckDir,
40
+ skill,
41
+ includeBuffers: true,
42
+ });
101
43
  if (problems.length) {
102
44
  const err = new Error(`deck has ${problems.length} authoring error(s):\n - ${problems.join("\n - ")}`);
103
45
  err.problems = problems;
@@ -174,6 +116,113 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
174
116
  return { outPath: finalOut, slideCount: output.length, warnings };
175
117
  }
176
118
 
119
+ // Parse and structurally validate a fill-template deck without mutating it.
120
+ // External packs use this generic inspection surface to run their own domain
121
+ // checks before invoking build-pptx. Buffers are opt-in because JSON callers
122
+ // must receive a compact, serializable sequence.
123
+ export function inspectFillTemplateDeck({
124
+ deckDir,
125
+ skill,
126
+ allowUnrenderedFreeform = false,
127
+ includeBuffers = false,
128
+ }) {
129
+ const pack = loadPack(skill);
130
+ const locale = skill.locale || "en";
131
+ const fieldsByNo = pack.fields[locale] ?? {};
132
+ const repeatsByNo = pack.repeats[locale] ?? {};
133
+ const slidesDir = path.join(deckDir, "slides");
134
+ const problems = [];
135
+ const warnings = [];
136
+ const sequence = [];
137
+
138
+ if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
139
+ problems.push(
140
+ `template "${skill.templateKey}" not found in pack ${skill.skillDir} — check the pack manifest templates`,
141
+ );
142
+ }
143
+ if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
144
+ problems.push(`expected 'slides/' folder in ${deckDir}`);
145
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
146
+ }
147
+
148
+ const entries = fs.readdirSync(slidesDir)
149
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
150
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
151
+ if (entries.length === 0) {
152
+ problems.push(`no .jsx or .pptx slides in ${slidesDir}`);
153
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
154
+ }
155
+
156
+ const renderedStems = new Set(
157
+ entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
158
+ );
159
+
160
+ for (const file of entries) {
161
+ const full = path.join(slidesDir, file);
162
+ if (file.endsWith(".pptx")) {
163
+ sequence.push({
164
+ kind: "insert",
165
+ file,
166
+ ...(includeBuffers ? { buf: fs.readFileSync(full) } : {}),
167
+ });
168
+ continue;
169
+ }
170
+ if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
171
+
172
+ const src = fs.readFileSync(full, "utf8");
173
+ const component = src.match(/<([A-Z][A-Za-z0-9]*)/)?.[1] ?? null;
174
+ let layout = null;
175
+ if (component) {
176
+ try {
177
+ layout = layoutNumber(component, locale, pack.components);
178
+ } catch (err) {
179
+ problems.push(err.message);
180
+ continue;
181
+ }
182
+ }
183
+
184
+ // A pack validator needs to see freeform source positions even before the
185
+ // paired add-in artifact exists. The build path still rejects such a
186
+ // source until ensureFreeformRenders has produced its .pptx sibling.
187
+ if (!layout && allowUnrenderedFreeform) {
188
+ sequence.push({ kind: "freeform", file, component });
189
+ warnings.push(`${file}: freeform source has no rendered .pptx artifact yet`);
190
+ continue;
191
+ }
192
+
193
+ let parsed;
194
+ try {
195
+ parsed = parseSlideFile(src, file);
196
+ } catch (err) {
197
+ problems.push(err.message);
198
+ continue;
199
+ }
200
+ layout = layoutNumber(parsed.component, locale, pack.components);
201
+ if (!layout) {
202
+ problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
203
+ `rendered artifact next to it — render slides/${file.replace(/\.jsx$/, "")}.pptx ` +
204
+ `through the paired add-in before building`);
205
+ continue;
206
+ }
207
+ if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
208
+ const no = String(layout.no);
209
+
210
+ if ((pack.policy.freeform ?? []).includes(no)) {
211
+ if (Object.keys(parsed.props).length > 0) {
212
+ problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
213
+ `Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
214
+ continue;
215
+ }
216
+ warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
217
+ }
218
+
219
+ validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
220
+ sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
221
+ }
222
+
223
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
224
+ }
225
+
177
226
  // ---------------------------------------------------------------------------
178
227
  // Pack
179
228
  // ---------------------------------------------------------------------------
@@ -266,77 +315,6 @@ function editDistance(a, b) {
266
315
  return dp[a.length][b.length];
267
316
  }
268
317
 
269
- // ---------------------------------------------------------------------------
270
- // Cross-slide identities (the catalog's consistency table, as code)
271
- // ---------------------------------------------------------------------------
272
-
273
- const asLines = (v) => (Array.isArray(v) ? v : String(v ?? "").split("\n")).map((s) => s.trim()).filter(Boolean);
274
-
275
- function checkIdentities(sequence, warnings, problems) {
276
- const slide = (no) => sequence.filter((s) => s.kind === "master" && s.no === no);
277
- const approach = slide(10)[0];
278
- const details = slide(11);
279
- const gantt = slide(13)[0];
280
- const calc = slide(16)[0];
281
-
282
- const wpItems = approach?.props?.repeat?.workPackages ?? null;
283
- const ganttItems = gantt?.props?.repeat?.workPackages ?? null;
284
-
285
- if (wpItems && details.length && wpItems.length !== details.length) {
286
- problems.push(`WP count mismatch: slide 10 declares ${wpItems.length} work packages but there are ${details.length} WP-detail files (slide 11)`);
287
- }
288
- // Every WP detail must carry FULL bullet lists — a section left empty (or a
289
- // one-liner) is a defect in a client deliverable, not a style choice.
290
- details.forEach((d, i) => {
291
- for (const section of ["services", "assumptions", "deliverables"]) {
292
- const lines = asLines(d.props?.[section]);
293
- if (lines.length === 0) {
294
- problems.push(`${d.file}: WP detail ${i + 1} has no "${section}" — every WP detail needs all three bullet lists filled`);
295
- } else if (lines.length < 3) {
296
- warnings.push(`${d.file}: "${section}" has only ${lines.length} bullet(s) — WP details expect full lists (3–5)`);
297
- }
298
- }
299
- });
300
- if (wpItems && ganttItems && wpItems.length !== ganttItems.length) {
301
- problems.push(`WP count mismatch: slide 10 has ${wpItems.length} work packages, the project plan (13) has ${ganttItems.length} rows`);
302
- }
303
- if (ganttItems && details.length === ganttItems.length) {
304
- ganttItems.forEach((item, i) => {
305
- const canonical = asLines(details[i]?.props?.deliverables);
306
- const row = asLines(item.deliverable);
307
- if (canonical.length && row.length && canonical.join("\n") !== row.join("\n")) {
308
- problems.push(`deliverables drift: gantt row ${i + 1} must repeat WP-${i + 1}'s deliverables verbatim (slide 11 owns them)`);
309
- }
310
- });
311
- }
312
- if (wpItems && ganttItems) {
313
- ganttItems.forEach((item, i) => {
314
- if (item.title && wpItems[i]?.title && item.title !== wpItems[i].title) {
315
- problems.push(`WP name drift: slide 10 card ${i + 1} is "${wpItems[i].title}" but gantt row ${i + 1} is "${item.title}"`);
316
- }
317
- });
318
- }
319
- for (const [frameId, rows] of Object.entries(calc?.props?.tables ?? {})) {
320
- if (!Array.isArray(rows) || !wpItems) continue;
321
- const labels = rows.slice(1, -1).map((r) => String(r[0] ?? ""));
322
- labels.forEach((label, i) => {
323
- if (wpItems[i]?.title && label && label !== wpItems[i].title) {
324
- problems.push(`WP name drift: calculation table ${frameId} row ${i + 1} is "${label}" but slide 10 card ${i + 1} is "${wpItems[i].title}"`);
325
- }
326
- });
327
- }
328
- // Price echo: the contracted total should appear in the management summary.
329
- const costRows = calc?.props?.tables?.["15"];
330
- if (Array.isArray(costRows)) {
331
- const grand = String(costRows[costRows.length - 1]?.slice(-1)[0] ?? "");
332
- const digits = grand.replace(/[^\d.,]/g, "");
333
- const summary = slide(7)[0];
334
- if (digits && summary?.props?.approach && !String(summary.props.approach).includes(digits)) {
335
- warnings.push(`slide 7's approach paragraph does not mention the contracted total ${grand} from table 15`);
336
- }
337
- }
338
- }
339
-
340
318
  // ---------------------------------------------------------------------------
341
319
  // Per-slide ops
342
320
  // ---------------------------------------------------------------------------