slidemind 0.3.14 → 0.3.16

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Connect Claude Code to the slidemind PowerPoint add-in — build and edit native, on-brand PowerPoint decks against the active corporate design, from the terminal.",
5
5
  "author": {
6
6
  "name": "statworx",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Connect Codex to the slidemind PowerPoint add-in — build and edit native, on-brand PowerPoint decks against the active corporate design. Requires the slidemind CLI (npm install -g slidemind); the skills explain the install when it is missing.",
5
5
  "author": {
6
6
  "name": "statworx",
package/AGENTS.md CHANGED
@@ -186,7 +186,7 @@ slidemind/
186
186
  │ │ download-bundle · ready · diagnose · read · session (login/whoami/logout) · install (init/uninstall/update) · setup
187
187
  │ ├── bridge/ add-in local-bridge: client.mjs (the public surface; implementation split by job under client/: transport, presentations, pane-ops, brands, render, engine-routing, engine-search, engine-slides), bundle.mjs (materialize), build-and-push.mjs (server render; native-slide splicing), engine-download.mjs + recolor.mjs (image/icon fetch + recolour),
188
188
  │ │ setup-claim.mjs (decode/redeem the add-in's one-command claim — the only connect path) + http-json.mjs + config.mjs (per-backend sessions)
189
- │ ├── builders/ build.mjs (HTML preview; the page itself is preview.html), review.mjs (spatial preview-review gate + local server; stable per-deck port, graceful fallback, tab reuse across rounds), plan.mjs (plan sign-off gate) + plan-overlay.js + plan-md.mjs (markdown→HTML) + gate-server.mjs (shared gate primitives), resolve-skill.mjs (→ bundle), lint.mjs (runs the add-in's engine)
189
+ │ ├── builders/ layout-jobs.mjs (a layout's job — cover/agenda/divider/quote/closing/content — from the bundle's PLACEHOLDERS `job` or the add-in's name table; the deck frame is composed from it, docs/slidemind-cli-connect/17-deck-frame.md), build.mjs (HTML preview; the page itself is preview.html), review.mjs (spatial preview-review gate + local server; stable per-deck port, graceful fallback, tab reuse across rounds), plan.mjs (plan sign-off gate) + plan-overlay.js + plan-md.mjs (markdown→HTML) + gate-server.mjs (shared gate primitives), resolve-skill.mjs (→ bundle), lint.mjs (runs the add-in's engine)
190
190
  │ ├── pptx/ host-free local-.pptx support (fflate-based): read-local.mjs (read a file's slides — output-identical to the host read), slide-swap.mjs (fold a picked slide into a deck as native .pptx — swaps it into the lowest slot the splicer reads, then prunes the package to that slide's dependency closure so a slice carries only what it needs), zip.mjs + ooxml.mjs (shared unzip/order helpers)
191
191
  │ ├── lib/ deck-workspace bookkeeping: adopt.mjs (rebuild a deck from a file's embedded source + convert its foreign slides through the add-in's convert-pptx route), conversions.mjs (the deck's `conversions/<stem>.json` identity+evidence records and the preserve source package push threads back), slide-guids.mjs, slide-order.mjs, reused-slides.mjs
192
192
  │ └── installers/ per-harness installers: claude.mjs, codex.mjs, opencode.mjs, lib.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -360,6 +360,14 @@ part on its own route instead of forcing the whole thing onto one.
360
360
  - **Read scope from the wording**, don't ask redundantly: single slide ("make a slide about X"),
361
361
  small set ("add three slides on Y"), or whole deck ("a deck for Friday"). Only ask "one slide, a
362
362
  small set, or a full deck?" when truly ambiguous.
363
+ **Choose layouts from evidence.** Honor explicit choices first. For an extension, read the target deck and its section/insertion context: prefer the section's suitable body layouts, then the deck's common layouts. For a new presentation use the catalog's observed brand usage. Frequency wins among layouts that can fit the content; do not force layout variety or an 80/20 quota. Names, previews, hints and content areas guide semantic choices. Preserve native names; copy the exact component name and layoutKey from the catalog. Verified dark/light counterparts can be switched, but retain the deck's established pattern (for example dark dividers with light body slides). Never assume two layouts are equivalent just because names or colors resemble each other.
364
+
365
+ **Record the slide's role, not a permanent layout classification.** Every storyboard carries `role: body | frame`; frame slides also carry `frameKind: cover | agenda | divider | closing`. Also record `layoutSelection: automatic | explicit`, `layoutKey` when provided, and a short `layoutReason`. Use `sectionId` for meaningful sections. A quote is ordinarily a body slide. Choose frame layouts from the available native names, previews and slots; no stored layout job is required. Use an empty blocks array only when the layout has no authored content area. Covers can require images; agendas can require composition. No frame mockup is generated by default, but source every required image, fill every required slot, and perform the normal fit and rendered checks.
366
+
367
+ **Deliverable intent is independent of delivery target.** `slides` adds only the requested slides, with no automatic frame; an explicitly requested cover or divider is still valid. `deck` adds the appropriate available frame whether delivered as a file or into PowerPoint. A deck request's count means body slides unless the user states a total limit. Show body and total counts separately. Default to available cover/closing, agenda at four body slides or two meaningful sections, dividers for multiple meaningful sections; never invent sections for these thresholds. Record an unavailable frame layout as an omission in the plan. User choices override defaults. Never overwrite a signed-off layout because usage evidence changed.
368
+
369
+ For an existing target or explicit reference deck, run `slidemind read <presId|file.pptx> --mode summary --deck <deck>` before planning to bind its usage snapshot to this workspace. Refresh that read when the target changes. Standalone decks without a reference use brand usage and require no live window.
370
+
363
371
  - **Which corporate design — ask whenever more than one is cached.** `ready`'s `designs` list is
364
372
  the menu: with a single entry there is no question; with several, put the choice to the user
365
373
  **before planning**, even if PowerPoint has one of them open (that one is only the suggestion).
@@ -388,11 +396,25 @@ part on its own route instead of forcing the whole thing onto one.
388
396
  - **Replay and get sign-off — always, at every size.** Persist a **story-focused plan with body
389
397
  storyboards** in `decks/<slug>/plan.md` and run the gate on it, whether the request is one slide or
390
398
  forty. A single slide gets the same document with one `### Slide N` section: shorter, never
391
- skipped. Two top-level sections in order:
392
- - `## Red thread` — one presentation **move** per slide, as bullets or one arrow-separated line.
393
- The number of moves MUST equal the number of `### Slide N` sections: when the user names a slide
394
- count, write exactly that many moves and sections — never expand the narrative arc beyond the
395
- requested count. Name the *teaching function* of each slide — short verb+noun labels for what
399
+ skipped. Three top-level sections in order — first `## Deliverable`, four lines:
400
+ ```
401
+ ## Deliverable
402
+ - shape: deck # deck | slides
403
+ - body slides: 3 # the count the user asked for, body only
404
+ - frame: cover, closing # the frame jobs this deck ships, or none
405
+ - target: file # file | push
406
+ ```
407
+ (`slidemind plan` prints a `[plan:warn]` line for every disagreement between this block, the
408
+ storyboards' explicit roles and selected layouts — read them before the gate opens.) Then:
409
+ - `## Red thread` — one presentation **move** per **body** slide, as bullets or one arrow-separated
410
+ line, followed by one `Frame:` line naming the frame slides in play order (`Frame: cover ·
411
+ closing`, or `Frame: none`). The number of body moves MUST equal the number of body `### Slide N`
412
+ sections: use the agreed Deliverable body count, accounting for any explicit total limit or
413
+ individual frame-slide request. Never expand that agreed body count. Frame slides get their own `### Slide
414
+ N` sections in play order (cover = slide 1, closing = last, a divider directly before its
415
+ section). Use `"blocks": []` only when the layout has no authored content region; otherwise
416
+ sketch the required composition. Put the slot text in `contentNotes` and the story beat in
417
+ `keyMessage`. Name the *teaching function* of each slide — short verb+noun labels for what
396
418
  the audience is doing cognitively at that step — never its content: no claims, facts, metrics,
397
419
  client-specific terms, slide copy, generic section names, or wording copied from these
398
420
  instructions.
@@ -447,6 +469,8 @@ part on its own route instead of forcing the whole thing onto one.
447
469
  ```storyboard-canvas
448
470
  {
449
471
  "version": 1,
472
+ "role": "body",
473
+ "layoutSelection": "automatic",
450
474
  "slide": 1,
451
475
  "layout": "<ComponentName from design.md's Layout Catalog>",
452
476
  "blocks": [
@@ -594,7 +618,8 @@ From here:
594
618
  `.pptx` that coexists with your JSX in play order and survives re-push. The slide can come from
595
619
  an engine search **or** from a local `.pptx` on disk (`add-slide --from <file.pptx> --pick <n>
596
620
  --deck <slug>`, host-free); both fold in identically — see `slidemind-search`.
597
- 3. **Reopen the concepts, then author in small batches.** Once the searches and asset downloads are
621
+ 3. **Frame slides from the plan.** A storyboard with `role: frame` gets no default concept mockup. Author its required text, images and composition according to its actual layout capabilities; source required assets even if the asset-plan section carries a frame marker. Covers go first, closing last, dividers before their sections. Run the normal fit and rendered checks.
622
+ 4. **Reopen the concepts, then author in small batches.** Once the searches and asset downloads are
598
623
  complete, split a concept-seeded deck into batches of at most **3 slides**. For each batch, first
599
624
  Read its matching anchored `decks/<slug>/concepts/concept-slide-NN.png` files together at full
600
625
  detail; as the **very next tool action**, create those slides'
@@ -424,7 +424,10 @@ export function nodeRequestToFile(urlString, init = {}, destPath) {
424
424
  reject(err);
425
425
  });
426
426
  out.on("error", reject);
427
- out.on("finish", () =>
427
+ // `close`, not `finish`: finish fires before the fd is released, and
428
+ // on Windows the caller's next rename/rm of that file then hits
429
+ // EPERM/ENOTEMPTY (flaky CI teardown was the symptom).
430
+ out.on("close", () =>
428
431
  resolve({
429
432
  status,
430
433
  statusText: res.statusMessage ?? "",
@@ -37,7 +37,14 @@ export function seedAssetPlan(deckDir, images) {
37
37
  const slides = [...images]
38
38
  .sort((a, b) => a.slideNumber - b.slideNumber)
39
39
  .map((img) =>
40
- [
40
+ img.frame
41
+ ? [
42
+ `## Slide ${pad2(img.slideNumber)} — ${img.label ?? `Slide ${img.slideNumber}`}`,
43
+ `jsx: slides/${pad2(img.slideNumber)}-<name>.jsx`,
44
+ `(frame slide — ${img.frame}: chrome slots only, authored from the plan as the layout's props; no inventory, no mockup, no verdict)`,
45
+ "",
46
+ ].join("\n")
47
+ : [
41
48
  `## Slide ${pad2(img.slideNumber)} — ${img.label ?? `Slide ${img.slideNumber}`}`,
42
49
  `jsx: slides/${pad2(img.slideNumber)}-<name>.jsx`,
43
50
  "regions:",
@@ -0,0 +1,129 @@
1
+ // Layout JOB — what a bundle layout is for (cover / agenda / divider / quote /
2
+ // closing / content / blank), brand-agnostically.
3
+ //
4
+ // The add-in writes `"job"` into every layout's `// PLACEHOLDERS` metadata
5
+ // (`lib/corporate-design/layout-job.ts` is the owner of the rule). Bundles
6
+ // emitted before that field existed carry none, so this module reads the
7
+ // field when present and otherwise runs the SAME name/placeholder table the
8
+ // add-in uses — a port, not a second opinion. Consumers: `concepts` (frame
9
+ // slides get no mockup), the plan gate (frame ⇔ shape validation, layout
10
+ // picker), the sim checker (body-only slide counts).
11
+ //
12
+ // docs/slidemind-cli-connect/17-deck-frame.md
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ export const FRAME_JOBS = new Set(["cover", "agenda", "divider", "closing", "frame"]);
18
+
19
+ const NAME_KEYWORDS = [
20
+ ["agenda", ["agenda", "inhaltsverzeichnis", "inhaltsubersicht", "contents", "overview", "ubersicht", "uebersicht", "table of contents", "toc"]],
21
+ ["closing", ["closing", "ending", "end slide", "abschluss", "schluss", "ende", "thank", "danke", "contact", "kontakt", "back cover", "last"]],
22
+ ["divider", ["divider", "section", "sektion", "abschnitt", "chapter", "kapitel", "trenner", "zwischentitel", "break"]],
23
+ ["quote", ["quote", "zitat", "testimonial", "statement"]],
24
+ ["cover", ["cover", "title slide", "titelfolie", "titel", "deckblatt", "start", "front", "opening", "intro"]],
25
+ ];
26
+
27
+ function fold(value) {
28
+ return String(value ?? "")
29
+ .toLowerCase()
30
+ .replace(/ä/g, "a")
31
+ .replace(/ö/g, "o")
32
+ .replace(/ü/g, "u")
33
+ .replace(/ß/g, "ss")
34
+ .replace(/[_\-./]+/g, " ")
35
+ .replace(/\s+/g, " ")
36
+ .trim();
37
+ }
38
+
39
+ function byName(...names) {
40
+ const haystack = names.map(fold).filter(Boolean).join(" | ");
41
+ if (!haystack) return null;
42
+ const saysContent = ["inhalt", "content", "body", "text", "text slide"].some((w) => haystack.includes(w));
43
+ for (const [job, words] of NAME_KEYWORDS) {
44
+ if (words.some((w) => haystack.includes(w))) {
45
+ if (job === "cover" && saysContent) continue;
46
+ return job;
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** Same decision table as the add-in's classifyLayoutJob. */
53
+ export function classifyLayoutJob({ layoutType = null, placeholderTypes = [], hasCanvas = false, nativeLayoutName = null, componentName = null }) {
54
+ const type = String(layoutType ?? "").trim();
55
+ const ph = new Set(placeholderTypes.map((t) => String(t).trim()));
56
+ const named = byName(nativeLayoutName, componentName);
57
+ if (type === "title" || type === "ctrTitle") return "cover";
58
+ if (type === "secHead") return named === "agenda" ? "agenda" : "divider";
59
+ if (type === "blank" && !hasCanvas) return named ?? "blank";
60
+ if (!hasCanvas) {
61
+ if (ph.has("ctrTitle")) return "cover";
62
+ if (ph.has("subTitle") && !ph.has("body")) return "cover";
63
+ if (named) return named;
64
+ if (ph.size === 0) return "blank";
65
+ return "divider";
66
+ }
67
+ if (named === "agenda" || named === "quote") return named;
68
+ return "content";
69
+ }
70
+
71
+ export const isFrameJob = (job) => FRAME_JOBS.has(job);
72
+
73
+ const PLACEHOLDERS_PREFIX = "// PLACEHOLDERS: ";
74
+
75
+ /** `{ componentName → { job, nativeLayoutName, hasCanvas, fromBundle } }` for every layouts/*.jsx. */
76
+ export function layoutJobs(bundleDir) {
77
+ const out = new Map();
78
+ const dir = bundleDir ? path.join(bundleDir, "layouts") : null;
79
+ if (!dir || !fs.existsSync(dir)) return out;
80
+ for (const file of fs.readdirSync(dir)) {
81
+ if (!file.endsWith(".jsx")) continue;
82
+ const componentName = file.slice(0, -4);
83
+ let source = "";
84
+ try {
85
+ source = fs.readFileSync(path.join(dir, file), "utf8");
86
+ } catch {
87
+ continue;
88
+ }
89
+ let meta = null;
90
+ for (const line of source.split("\n")) {
91
+ if (!line.startsWith(PLACEHOLDERS_PREFIX)) continue;
92
+ try {
93
+ meta = JSON.parse(line.slice(PLACEHOLDERS_PREFIX.length));
94
+ } catch {}
95
+ break;
96
+ }
97
+ const nativeLayoutName = meta?.nativeLayoutName ?? componentName;
98
+ const hasCanvas = Boolean(meta?.content) || /\{children\}/.test(source);
99
+ const job =
100
+ typeof meta?.job === "string"
101
+ ? meta.job
102
+ : classifyLayoutJob({
103
+ placeholderTypes: (meta?.chrome ?? []).map((c) => c.type),
104
+ hasCanvas,
105
+ nativeLayoutName,
106
+ componentName,
107
+ });
108
+ out.set(componentName, { job, nativeLayoutName, hasCanvas, fromBundle: typeof meta?.job === "string" });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** The job a layout NAME alone implies (cover/agenda/divider/quote/closing), or null. */
114
+ export function nameJob(...names) {
115
+ return byName(...names);
116
+ }
117
+
118
+ /** Job of one component name: the bundle's field, else what the name says, else `content`. */
119
+ export function jobOf(jobs, componentName) {
120
+ if (!componentName) return "content";
121
+ return jobs.get(componentName)?.job ?? nameJob(componentName) ?? "content";
122
+ }
123
+
124
+ /** Explicit planned role wins; layout jobs are read compatibility for old plans only. */
125
+ export function plannedJob(data, jobs = new Map()) {
126
+ if (data?.role === "body") return "content";
127
+ if (data?.role === "frame") return FRAME_JOBS.has(data.frameKind) ? data.frameKind : "frame";
128
+ return jobOf(jobs, data?.layout);
129
+ }
@@ -1134,6 +1134,9 @@
1134
1134
  var d = {};
1135
1135
  for (var k in chipSegment.data) d[k] = chipSegment.data[k];
1136
1136
  d.layout = componentName;
1137
+ d.layoutSelection = "explicit";
1138
+ d.layoutKey = (CFG.layouts || []).find(function(o) { return o.componentName === componentName; })?.layoutKey;
1139
+ delete d.layoutReason;
1137
1140
  commitStoryboard(chipSegment, d);
1138
1141
  },
1139
1142
  });
@@ -1602,6 +1605,7 @@
1602
1605
 
1603
1606
  function renderLayoutPicker(value, opts) {
1604
1607
  var options = Array.isArray(CFG.layouts) ? CFG.layouts : [];
1608
+ if (opts.bodyOnly) options = options.filter(function(o) { return o.canvasBounds || o.contentAreas?.length; });
1605
1609
  var root = el("div", "plan-storyboard-layout-picker" + (opts.className ? " " + opts.className : ""));
1606
1610
  var selected = null;
1607
1611
  for (var i = 0; i < options.length; i++) {
@@ -1623,7 +1627,7 @@
1623
1627
  current.appendChild(thumb);
1624
1628
  }
1625
1629
  var nameSpan = el("span", "plan-storyboard-layout-name");
1626
- nameSpan.textContent = value || opts.placeholder || "No layout chosen";
1630
+ nameSpan.textContent = (selected && selected.nativeLayoutName) || value || opts.placeholder || "No layout chosen";
1627
1631
  current.appendChild(nameSpan);
1628
1632
  if (value && options.length > 0 && !selected) {
1629
1633
  var badge = el("span", "plan-storyboard-layout-unknown");
@@ -1676,7 +1680,9 @@
1676
1680
  }
1677
1681
  var text = el("span", "plan-storyboard-layout-option-text");
1678
1682
  var n = el("span", "plan-storyboard-layout-name");
1679
- n.textContent = option.componentName;
1683
+ n.textContent = option.nativeLayoutName || option.componentName;
1684
+ if (options.filter(function (other) { return other.nativeLayoutName === option.nativeLayoutName; }).length > 1 && option.selection?.masterName) n.textContent += " · " + option.selection.masterName;
1685
+ if (option.selection) n.title = option.selection.masterName || "";
1680
1686
  text.appendChild(n);
1681
1687
  if (option.purpose) {
1682
1688
  var p = el("span", "plan-storyboard-layout-purpose");
@@ -1685,11 +1691,11 @@
1685
1691
  }
1686
1692
  var c = el("span", "plan-storyboard-layout-canvas");
1687
1693
  c.textContent = contentAreaSummary(option);
1694
+ if (option.selection) { var u = option.selection; c.textContent += u.currentCount ? " · " + u.currentCount + " uses in this presentation" : u.usage.count ? " · " + u.usage.count + " observed uses" : ""; }
1688
1695
  text.appendChild(c);
1689
1696
  btn.appendChild(text);
1690
1697
  btn.addEventListener("click", function () {
1691
1698
  closeMenu();
1692
- if (option.componentName === value) return;
1693
1699
  opts.onPick(option.componentName);
1694
1700
  });
1695
1701
  menu.appendChild(btn);
@@ -1704,7 +1710,7 @@
1704
1710
  // pick binds EVERY storyboard's `layout` in one commit.
1705
1711
  function renderDeckLayoutKnob(storyboardData) {
1706
1712
  var wrap = el("div", "plan-storyline-deck-layout");
1707
- wrap.appendChild(el("span", "plan-storyline-deck-layout-label", "Deck layout"));
1713
+ wrap.appendChild(el("span", "plan-storyline-deck-layout-label", "Body defaults"));
1708
1714
  var seen = {};
1709
1715
  var distinct = 0;
1710
1716
  for (var i = 0; i < storyboardData.length; i++) {
@@ -1718,17 +1724,39 @@
1718
1724
  wrap.appendChild(
1719
1725
  renderLayoutPicker(common || undefined, {
1720
1726
  placeholder: distinct > 1 ? "Mixed" : undefined,
1721
- ariaLabel: "Set one layout for every slide",
1727
+ ariaLabel: "Set the default for compatible body slides",
1728
+ bodyOnly: true,
1722
1729
  onPick: function (componentName) {
1723
- commitPlan(PLAN.setStoryboardLayoutForAll(S.value, componentName));
1730
+ commitPlan(PLAN.setStoryboardLayoutForAll(S.value, componentName, CFG.layouts || []));
1724
1731
  },
1725
1732
  }),
1726
1733
  );
1734
+ if ((CFG.layouts || []).some(function(o) { return o.selection?.counterpartKey; })) {
1735
+ ["light", "dark"].forEach(function(tone) {
1736
+ var button = el("button", "plan-storyboard-layout-current", tone === "dark" ? "Dark variants" : "Light variants");
1737
+ button.type = "button";
1738
+ button.addEventListener("click", function() { commitPlan(PLAN.setStoryboardStyle(S.value, tone, CFG.layouts || [])); });
1739
+ wrap.appendChild(button);
1740
+ });
1741
+ }
1727
1742
  return wrap;
1728
1743
  }
1729
1744
 
1730
1745
  function renderDetails(data, segment) {
1731
1746
  var aside = el("aside", "plan-storyboard-detail");
1747
+ var roleSelect = document.createElement("select");
1748
+ roleSelect.setAttribute("aria-label", "Slide role");
1749
+ ["body", "cover", "agenda", "divider", "closing"].forEach(function(role) {
1750
+ var option = document.createElement("option"); option.value = role; option.textContent = role;
1751
+ option.selected = role === (data.role === "frame" ? data.frameKind : "body"); roleSelect.appendChild(option);
1752
+ });
1753
+ roleSelect.addEventListener("change", function() { var d = Object.assign({}, data);
1754
+ d.role = roleSelect.value === "body" ? "body" : "frame";
1755
+ d.frameKind = d.role === "frame" ? roleSelect.value : undefined;
1756
+ commitStoryboard(segment, d);
1757
+ });
1758
+ aside.appendChild(roleSelect);
1759
+ if (data.layoutReason) aside.appendChild(el("div", "plan-storyboard-layout-purpose", data.layoutReason));
1732
1760
  aside.appendChild(el("div", "plan-storyboard-detail-label", "Layout"));
1733
1761
  aside.appendChild(
1734
1762
  renderLayoutPicker(data.layout, {
@@ -1736,6 +1764,9 @@
1736
1764
  var d = {};
1737
1765
  for (var k in data) d[k] = data[k];
1738
1766
  d.layout = componentName;
1767
+ d.layoutSelection = "explicit";
1768
+ d.layoutKey = (CFG.layouts || []).find(function(o) { return o.componentName === componentName; })?.layoutKey;
1769
+ delete d.layoutReason;
1739
1770
  commitStoryboard(segment, d);
1740
1771
  },
1741
1772
  }),
@@ -55,6 +55,8 @@ import { openInBrowser } from "./build.mjs";
55
55
  import { loadReviewState } from "./review.mjs";
56
56
  import { renderPlanMarkdown } from "./plan-md.mjs";
57
57
  import { splitPlanFrontmatter } from "./resolve-skill.mjs";
58
+ import { layoutJobs, isFrameJob, plannedJob } from "./layout-jobs.mjs";
59
+ import { splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
58
60
  import {
59
61
  FAVICON_LINK,
60
62
  MIME,
@@ -286,6 +288,7 @@ const PLAN_NAMESPACE_EXPORTS = [
286
288
  "splitPlanMarkdownIntoSegments",
287
289
  "replaceStoryboardSegment",
288
290
  "setStoryboardLayoutForAll",
291
+ "setStoryboardStyle",
289
292
  "extractRedThreadMessages",
290
293
  "findRedThreadSection",
291
294
  "parseStoryboardCanvas",
@@ -467,6 +470,95 @@ export function parsePlaceholderOutlines(source, layoutsCss) {
467
470
  // chrome-render thumbnail from `assets/chrome-<Name>.*` served at
468
471
  // `/brand/chrome/<file>`. No bundle → `{ layouts: [], chromeDir: null }` and
469
472
  // the picker degrades to read-only (the host-free contract holds).
473
+ // ---------------------------------------------------------------------------
474
+ // The plan's `## Deliverable` block and the deck frame it implies
475
+ // (docs/slidemind-cli-connect/17-deck-frame.md). Parsed leniently: the block
476
+ // is a contract the agent writes and the user edits by hand.
477
+ // ---------------------------------------------------------------------------
478
+
479
+ export function parseDeliverable(markdown) {
480
+ const m = /^##\s+Deliverable\s*\n([\s\S]*?)(?=^##\s|(?![\s\S]))/m.exec(markdown ?? "");
481
+ if (!m) return null;
482
+ const get = (key) => {
483
+ const r = new RegExp(`^\\s*-?\\s*${key}\\s*:\\s*(.+)$`, "im").exec(m[1]);
484
+ return r ? r[1].trim() : null;
485
+ };
486
+ const shape = (get("shape") ?? "").toLowerCase().replace(/[^a-z]/g, "");
487
+ const body = Number.parseInt(get("body slides") ?? get("body") ?? "", 10);
488
+ const frameRaw = (get("frame") ?? "").toLowerCase();
489
+ const frame = /^(none|-|—|no)\b/.test(frameRaw)
490
+ ? []
491
+ : frameRaw.split(/[,;/]+/).map((x) => x.trim().replace(/s$/, "")).filter(Boolean);
492
+ return {
493
+ shape: shape === "deck" || shape === "slides" ? shape : null,
494
+ bodySlides: Number.isFinite(body) ? body : null,
495
+ frame,
496
+ target: get("target"),
497
+ };
498
+ }
499
+
500
+ /**
501
+ * `[plan:warn]` lines for a plan whose deliverable block and storyboards do
502
+ * not agree with each other or with the bundle's layout jobs. Advisory: the
503
+ * gate still opens — the user decides in it — but the agent reads these
504
+ * before the user does.
505
+ */
506
+ export function planDeliverableWarnings(deckDir, markdown) {
507
+ const out = [];
508
+ const dir = resolveBundleDirForDeck(deckDir);
509
+ const jobs = layoutJobs(dir);
510
+ const beats = [];
511
+ for (const segment of splitPlanMarkdownIntoSegments(markdown ?? "")) {
512
+ if (segment.type !== "storyboard" || !segment.data) continue;
513
+ const layout = segment.data.layout ?? null;
514
+ if (!segment.data.role) out.push(`[plan:warn] slide ${segment.data.slide} has no role; legacy layout inference is being used. Write role: body | frame before the next approval.`);
515
+ if (segment.data.role === "frame" && !segment.data.frameKind) out.push(`[plan:warn] frame slide ${segment.data.slide} needs a frameKind.`);
516
+ beats.push({ slide: segment.data.slide, layout, job: plannedJob(segment.data, jobs) });
517
+ }
518
+ if (beats.length === 0) return out;
519
+ const frameBeats = beats.filter((b) => isFrameJob(b.job));
520
+ const bodyBeats = beats.filter((b) => !isFrameJob(b.job));
521
+ const d = parseDeliverable(markdown);
522
+ if (!d) {
523
+ out.push(
524
+ `[plan:warn] plan.md has no \`## Deliverable\` block (shape: deck | slides, body slides: N, frame: <jobs>, target). ` +
525
+ `Without it the frame (cover/agenda/dividers/closing) is unspecified — write the block before the gate.`,
526
+ );
527
+ return out;
528
+ }
529
+ if (d.shape === "deck" && frameBeats.length === 0) {
530
+ const offered = [...jobs.values()].filter((j) => isFrameJob(j.job)).map((j) => j.job);
531
+ out.push(
532
+ `[plan:warn] shape is deck but no storyboard sits on a frame layout — this design offers ${offered.length ? [...new Set(offered)].join(", ") : "no frame layouts"}; a deck ships its cover${offered.includes("closing") ? " and closing" : ""}.`,
533
+ );
534
+ }
535
+ if (d.shape === "slides" && frameBeats.length > 0 && d.frame.length === 0) {
536
+ out.push(
537
+ `[plan:warn] shape is slides but slide${frameBeats.length > 1 ? "s" : ""} ${frameBeats.map((b) => b.slide).join(", ")} sit on frame layouts (${[...new Set(frameBeats.map((b) => b.job))].join(", ")}) — slides extend a deck that has its own frame; drop them or set shape: deck.`,
538
+ );
539
+ }
540
+ if (d.bodySlides != null && d.bodySlides !== bodyBeats.length) {
541
+ out.push(
542
+ `[plan:warn] Deliverable says body slides: ${d.bodySlides}, the storyboards carry ${bodyBeats.length} body slide${bodyBeats.length === 1 ? "" : "s"} (frame slides excluded).`,
543
+ );
544
+ }
545
+ for (const want of d.frame) {
546
+ if (!isFrameJob(want)) continue;
547
+ if (!frameBeats.some((b) => b.job === want)) {
548
+ out.push(`[plan:warn] frame lists ${want} but no storyboard declares that frameKind.`);
549
+ }
550
+ }
551
+ const cover = frameBeats.find((b) => b.job === "cover");
552
+ if (cover && beats.some((b) => b.slide < cover.slide)) {
553
+ out.push(`[plan:warn] the cover is slide ${cover.slide} — a cover is slide 1.`);
554
+ }
555
+ const closing = frameBeats.find((b) => b.job === "closing");
556
+ if (closing && beats.some((b) => b.slide > closing.slide)) {
557
+ out.push(`[plan:warn] the closing is slide ${closing.slide} but slides follow it — a closing is the last slide.`);
558
+ }
559
+ return out;
560
+ }
561
+
470
562
  export function resolvePlanLayouts(deckDir) {
471
563
  const dir = resolveBundleDirForDeck(deckDir);
472
564
  if (!dir) return { layouts: [], chromeDir: null };
@@ -484,7 +576,9 @@ export function resolvePlanLayouts(deckDir) {
484
576
  // are the only place these bullets follow a ### heading in the generated
485
577
  // design.md.
486
578
  const purposes = new Map();
579
+ const selections = new Map();
487
580
  const canvases = new Map();
581
+ const jobs = layoutJobs(dir);
488
582
  try {
489
583
  const md = fs.readFileSync(path.join(dir, "design.md"), "utf8");
490
584
  let current = null;
@@ -495,6 +589,8 @@ export function resolvePlanLayouts(deckDir) {
495
589
  continue;
496
590
  }
497
591
  if (!current) continue;
592
+ const selection = /^- Selection:\s*(.+)$/.exec(line);
593
+ if (selection) { try { selections.set(current, JSON.parse(selection[1])); } catch {} }
498
594
  const purpose = /^- Purpose:\s*(.+)$/.exec(line);
499
595
  if (purpose) purposes.set(current, purpose[1].trim());
500
596
  // Newer add-ins write `- Content areas:`; the deployed one still writes
@@ -578,10 +674,19 @@ export function resolvePlanLayouts(deckDir) {
578
674
  if (canvasBounds && contentAreas.length === 0) contentAreas = [canvasBounds];
579
675
  }
580
676
  const chrome = chromeByComponent.get(componentName);
677
+ const jobInfo = jobs.get(componentName);
678
+ const selectionLine = /^\/\/ SELECTION: (.+)$/m.exec(source);
679
+ let selection = selections.get(componentName);
680
+ if (selectionLine) { try { selection = JSON.parse(selectionLine[1]); } catch {} }
581
681
  layouts.push({
582
682
  componentName,
583
683
  purpose: purposes.get(componentName) ?? null,
584
- mode: content ? "workhorse" : "purpose-built",
684
+ // Original names remain visible; legacy jobs are read compatibility only.
685
+ nativeLayoutName: selection?.nativeLayoutName ?? jobInfo?.nativeLayoutName ?? componentName,
686
+ selection,
687
+ layoutKey: selection?.layoutKey,
688
+ job: jobInfo?.job ?? (content ? "content" : "blank"),
689
+ mode: content ? "composition" : "purpose-built",
585
690
  canvas,
586
691
  canvasBounds,
587
692
  contentAreas,
@@ -589,6 +694,35 @@ export function resolvePlanLayouts(deckDir) {
589
694
  previewUrl: chrome ? `/brand/chrome/${chrome}` : null,
590
695
  });
591
696
  }
697
+ // Target context is opt-in, written by `read --deck`; it never changes the brand bundle.
698
+ try {
699
+ const context = JSON.parse(fs.readFileSync(path.join(deckDir, ".layout-context.json"), "utf8"));
700
+ for (const layout of layouts) {
701
+ const selected = context.selections?.[layout.componentName];
702
+ if (!layout.selection) continue;
703
+ // A modern scoped identity mismatch must never fall back to a name join.
704
+ if (selected?.layoutKey) {
705
+ if (selected.layoutKey !== layout.layoutKey) continue;
706
+ layout.selection = { ...layout.selection, currentCount: selected.currentCount,
707
+ sectionCount: selected.sectionCount };
708
+ } else if (context.selections === undefined &&
709
+ Object.hasOwn(context.layoutCounts ?? {}, layout.nativeLayoutName) &&
710
+ layouts.filter((other) => other.nativeLayoutName === layout.nativeLayoutName).length === 1) {
711
+ layout.selection = { ...layout.selection, currentCount: context.layoutCounts[layout.nativeLayoutName] };
712
+ }
713
+ const p = layout.selection;
714
+ p.source = p.sectionCount > 0 ? "section" : p.currentCount > 0 ? "presentation" : p.usage?.count > 0 ? "brand" : "suggested";
715
+ }
716
+ } catch {}
717
+ // Same precedence as the add-in profile: capability, section, target, brand.
718
+ // Brand ranks are supplied by the add-in; legacy bundles retain their order.
719
+ layouts.sort((a, b) => {
720
+ if (!a.selection || !b.selection) return (a.selection?.rank ?? Infinity) - (b.selection?.rank ?? Infinity);
721
+ return Number(b.selection.composition) - Number(a.selection.composition) ||
722
+ (b.selection.sectionCount ?? 0) - (a.selection.sectionCount ?? 0) ||
723
+ (b.selection.currentCount ?? 0) - (a.selection.currentCount ?? 0) ||
724
+ b.selection.usage.share - a.selection.usage.share || a.componentName.localeCompare(b.componentName);
725
+ });
592
726
  return { layouts, chromeDir: chromeFiles.length ? assetsDir : null };
593
727
  }
594
728
 
@@ -871,6 +1005,10 @@ export async function runPlanReview({
871
1005
  return;
872
1006
  }
873
1007
 
1008
+ for (const line of planDeliverableWarnings(deckDir, fs.readFileSync(planPath, "utf8"))) {
1009
+ process.stdout.write(` ${line}\n`);
1010
+ }
1011
+
874
1012
  // Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
875
1013
  if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") {
876
1014
  savePlanState(deckDir, {
@@ -120,14 +120,38 @@ export function replaceStoryboardSegment(markdown, segment, nextData) {
120
120
  // Deck-level layout knob: binds EVERY valid storyboard fence to `layout`.
121
121
  // Fences are spliced last-to-first so earlier offsets stay valid; malformed
122
122
  // fences are left untouched. Mirrors the add-in's setStoryboardLayoutForAll.
123
- export function setStoryboardLayoutForAll(markdown, layout) {
124
- let next = markdown;
125
- const boards = splitPlanMarkdownIntoSegments(markdown).filter(
126
- (segment) => segment.type === "storyboard" && segment.data !== null,
127
- );
128
- for (const segment of boards.reverse()) {
129
- if (segment.data.layout === layout) continue;
130
- next = replaceStoryboardSegment(next, segment, { ...segment.data, layout });
131
- }
132
- return next;
123
+ export function setStoryboardLayoutForAll(markdown, layout, options = []) {
124
+ let next = markdown;
125
+ const target = options.find((option) => option.componentName === layout);
126
+ // Unknown capabilities cannot safely support a bulk substitution.
127
+ if (!target || (!target.canvasBounds && !target.contentAreas?.length)) return markdown;
128
+ const boards = splitPlanMarkdownIntoSegments(markdown).filter((segment) => segment.type === "storyboard");
129
+ for (const segment of boards.reverse()) {
130
+ if (segment.type !== "storyboard" || !segment.data) continue;
131
+ const data = segment.data;
132
+ const current = options.find((option) => option.componentName === data.layout);
133
+ if (data.role === "frame" || data.layoutSelection === "explicit") continue;
134
+ if (!current || (!current.canvasBounds && !current.contentAreas?.length)) continue;
135
+ // Specialized geometries stay put. Bulk defaults change equivalent canvases only.
136
+ if (JSON.stringify(current.contentAreas ?? current.canvasBounds) !== JSON.stringify(target.contentAreas ?? target.canvasBounds)) continue;
137
+ if (data.layout === layout) continue;
138
+ next = replaceStoryboardSegment(next, segment, { ...data, role: "body", layout,
139
+ layoutKey: target.layoutKey, layoutSelection: "automatic", layoutReason: undefined });
140
+ }
141
+ return next;
142
+ }
143
+
144
+ /** Change only verified counterparts; preserve explicit layout choices. */
145
+ export function setStoryboardStyle(markdown, tone, options) {
146
+ let next = markdown;
147
+ for (const segment of splitPlanMarkdownIntoSegments(markdown).reverse()) {
148
+ if (segment.type !== "storyboard" || !segment.data || segment.data.layoutSelection === "explicit") continue;
149
+ const current = options.find((option) => option.componentName === segment.data.layout);
150
+ if (!current?.selection?.counterpartKey || current.selection.tone === tone) continue;
151
+ const targets = options.filter((option) => option.selection?.counterpartKey === current.selection.counterpartKey && option.selection?.tone === tone);
152
+ if (targets.length !== 1) continue;
153
+ next = replaceStoryboardSegment(next, segment, { ...segment.data, layout: targets[0].componentName,
154
+ layoutKey: targets[0].layoutKey, layoutSelection: "automatic", layoutReason: undefined });
155
+ }
156
+ return next;
133
157
  }
@@ -157,6 +157,15 @@ export function parseStoryboardCanvas(raw) {
157
157
  SB_MAX_VISUAL_FAMILY_LENGTH,
158
158
  );
159
159
  const layout = sbAsTrimmedString(parsed.layout, SB_MAX_LAYOUT_NAME_LENGTH);
160
+ const selectionFields = {};
161
+ if (parsed.role === "body" || parsed.role === "frame") selectionFields.role = parsed.role;
162
+ if (parsed.role === "frame" && ["cover", "agenda", "divider", "closing"].includes(parsed.frameKind)) selectionFields.frameKind = parsed.frameKind;
163
+ if (parsed.role === "frame" && !selectionFields.frameKind) warnings.push("A frame slide needs frameKind: cover, agenda, divider or closing.");
164
+ if (parsed.layoutSelection === "automatic" || parsed.layoutSelection === "explicit") selectionFields.layoutSelection = parsed.layoutSelection;
165
+ for (const key of ["layoutKey", "layoutReason", "sectionId"]) {
166
+ const value = sbAsTrimmedString(parsed[key], 500);
167
+ if (value) selectionFields[key] = value;
168
+ }
160
169
  const imagePrompts = {};
161
170
  if (sbIsRecord(parsed.imagePrompts)) {
162
171
  for (const [key, value] of Object.entries(parsed.imagePrompts)) {
@@ -184,6 +193,7 @@ export function parseStoryboardCanvas(raw) {
184
193
  blocks,
185
194
  ...(visualFamily ? { visualFamily } : {}),
186
195
  ...(layout ? { layout } : {}),
196
+ ...selectionFields,
187
197
  ...(keyMessage ? { keyMessage } : {}),
188
198
  ...(contentNotes?.length ? { contentNotes } : {}),
189
199
  ...(Object.keys(imagePrompts).length > 0 ? { imagePrompts } : {}),
@@ -116,6 +116,28 @@ export async function cmdConceptImages(rest) {
116
116
  pathToFileURL(path.join(BUILDERS_DIR, "visual-families.mjs")).href
117
117
  );
118
118
  const planned = new Set(families.plannedSlideNumbers(planMarkdown));
119
+ // Frame slides (cover / agenda / divider / closing by the layout's job) get
120
+ // no mockup: they are chrome with text slots, authored from the plan. The
121
+ // server skips them too; taking them out of `planned` keeps them out of the
122
+ // "missing" list and the stale prune. (docs/…/17-deck-frame.md §2.5)
123
+ const jobsMod = await import(pathToFileURL(path.join(BUILDERS_DIR, "layout-jobs.mjs")).href);
124
+ const sbMod = await import(pathToFileURL(path.join(BUILDERS_DIR, "storyboard-markdown.mjs")).href);
125
+ const bundleMod = await import(pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href);
126
+ const jobs = jobsMod.layoutJobs(bundleMod.resolveBundleDirForDeck(deckDir));
127
+ const frameSlides = [];
128
+ for (const segment of sbMod.splitPlanMarkdownIntoSegments(planMarkdown)) {
129
+ if (segment.type !== "storyboard" || !segment.data?.layout) continue;
130
+ const job = jobsMod.plannedJob(segment.data, jobs);
131
+ if (jobsMod.isFrameJob(job) && planned.has(segment.data.slide)) {
132
+ planned.delete(segment.data.slide);
133
+ frameSlides.push({ slideNumber: segment.data.slide, job, layout: segment.data.layout });
134
+ }
135
+ }
136
+ if (frameSlides.length) {
137
+ process.stderr.write(
138
+ `slidemind: ${frameSlides.length} frame slide${frameSlides.length === 1 ? "" : "s"} (${frameSlides.map((f) => `${f.slideNumber}:${f.job}`).join(", ")}) — no mockup; author from the plan using the layout's slots and content areas, including required images.\n`,
139
+ );
140
+ }
119
141
 
120
142
  // Idempotent fast path: mockups already on disk are re-printed IMMEDIATELY.
121
143
  // Generation takes 1-2 minutes and agent harnesses time commands out and
@@ -293,9 +315,12 @@ export async function cmdConceptImages(rest) {
293
315
  );
294
316
  const seeded = check.seedAssetPlan(
295
317
  deckDir,
296
- images
297
- .filter((img) => writtenNums.has(Number(img?.slideNumber)))
298
- .map((img) => ({ slideNumber: Number(img.slideNumber), label: img.label })),
318
+ [
319
+ ...images
320
+ .filter((img) => writtenNums.has(Number(img?.slideNumber)))
321
+ .map((img) => ({ slideNumber: Number(img.slideNumber), label: img.label })),
322
+ ...frameSlides.map((f) => ({ slideNumber: f.slideNumber, label: `${f.job} (${f.layout})`, frame: f.job })),
323
+ ],
299
324
  );
300
325
  finish({
301
326
  paths: written,
package/src/cli/deck.mjs CHANGED
@@ -490,6 +490,9 @@ export async function cmdViewDraft(rest) {
490
490
  ...rules.fill([{ stem, render }]),
491
491
  ...rules.typeScale([{ stem, render }], typeTokens),
492
492
  ...rules.overlap([{ stem, render }]),
493
+ // Text leaving its painted card — engines from 2026-09-06 on; older
494
+ // ones simply have no rule here.
495
+ ...(typeof rules.escape === "function" ? rules.escape([{ stem, render }]) : []),
493
496
  ].map((l) => l.trim());
494
497
 
495
498
  const written = [];
package/src/cli/help.mjs CHANGED
@@ -151,9 +151,10 @@ export function printHelp(cmd = null) {
151
151
  ` --all refresh every design already cached on this machine\n` +
152
152
  ` --force re-download even if the cached bundle is current\n` +
153
153
 
154
- ` slidemind read [presId|file.pptx] Pull the content of an open presentation (requires a host),\n` +
154
+ ` slidemind read [presId|file.pptx] [--deck <deck>] Pull the content of an open presentation (requires a host),\n` +
155
155
  ` or of a local .pptx path (read host-free from disk)\n` +
156
156
  ` --mode <m> summary | raw (default: raw)\n` +
157
+ ` --deck <deck> use this presentation as layout context for the plan\n` +
157
158
  ` --to <presId> target a specific window (required if >1 open)\n` +
158
159
  ` --json print the raw content payload\n` +
159
160
  ` slidemind push <deck> [opts] Build deck and push slides into a running PowerPoint\n` +
package/src/cli/read.mjs CHANGED
@@ -50,6 +50,7 @@ export async function cmdRead(rest) {
50
50
  let mode = "raw";
51
51
  let asJson = false;
52
52
  let toGiven = false;
53
+ let deckArg = null;
53
54
  let fileArg = null; // the positional (a candidate local .pptx path)
54
55
  for (let i = 0; i < rest.length; i++) {
55
56
  const a = rest[i];
@@ -63,6 +64,9 @@ export async function cmdRead(rest) {
63
64
  presId = rest[++i];
64
65
  if (!presId) fail("--to expects a presId");
65
66
  toGiven = true;
67
+ } else if (a === "--deck") {
68
+ deckArg = rest[++i];
69
+ if (!deckArg) fail("--deck expects a deck");
66
70
  } else if (a === "--json") {
67
71
  asJson = true;
68
72
  } else if (a.startsWith("-")) {
@@ -98,6 +102,7 @@ export async function cmdRead(rest) {
98
102
  } catch (err) {
99
103
  fail(`read failed: ${err.message}`);
100
104
  }
105
+ if (deckArg) saveLayoutContext(deckArg, res, p);
101
106
  printReadResult(res, asJson);
102
107
  return;
103
108
  }
@@ -116,5 +121,18 @@ export async function cmdRead(rest) {
116
121
  if (!res?.ok) {
117
122
  fail(`read failed: ${res?.error ?? "unknown error"}`);
118
123
  }
124
+ if (deckArg) saveLayoutContext(deckArg, res, entry.presId);
119
125
  printReadResult(res, asJson);
120
126
  }
127
+
128
+ // A reference must be deliberately bound to this workspace; it never changes brand evidence.
129
+ export function saveLayoutContext(deck, result, target) {
130
+ const deckDir = path.resolve(fs.existsSync(deck) ? deck : path.join(process.env.SLIDEMIND_DECKS || "decks", deck));
131
+ if (!fs.existsSync(deckDir) || !fs.statSync(deckDir).isDirectory()) fail("--deck must name an existing deck workspace");
132
+ const context = { ...result.layoutContext, target, observedAt: result.layoutContext?.observedAt || new Date().toISOString(),
133
+ layoutCounts: Object.fromEntries((result.slides || []).reduce((counts, slide) => {
134
+ if (slide.layout && !slide.hidden) counts.set(slide.layout, (counts.get(slide.layout) || 0) + 1);
135
+ return counts;
136
+ }, new Map())) };
137
+ fs.writeFileSync(path.join(deckDir, ".layout-context.json"), JSON.stringify(context, null, 2) + "\n");
138
+ }
@@ -35,6 +35,7 @@ export function readLocalPptx(buffer, { mode = "raw" } = {}) {
35
35
  const { title, subtitle, text } = slideText(xml);
36
36
  return {
37
37
  index: i + 1,
38
+ ...(/<p:sld\b[^>]*\bshow\s*=\s*["'](?:0|false)["']/.test(xml) ? { hidden: true } : {}),
38
39
  layout: layoutName(files, slidePath),
39
40
  title,
40
41
  subtitle,