slidemind 0.3.20 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,413 @@
1
+ // A STACK the reader declares (`spec.stacks`, concept-stacks.ts): rows of one
2
+ // structure repeated down a container — an icon disc, its text and the divider
3
+ // under them, four times; steps along a timeline — each row listed as the ids
4
+ // of its parts, with how a row's parts sit on the row's axis (`align`) and how
5
+ // the rows are spaced (`spacing`: even gaps, or the gaps as drawn). Code builds
6
+ // exactly that and nothing more: a row is laid out from its parts' CURRENT
7
+ // sizes (a text's measured height), the rows are placed in the band the stack
8
+ // owns inside its parent (what lies above and below the whole stack bounds it,
9
+ // recorded once from the painting), and a divider listed with a row moves
10
+ // with that row. Nothing here decides that a structure is a stack or that a
11
+ // part is centred; a stack the reader did not declare changes nothing. This
12
+ // replaced seating each row "middle" against the whole card (x101: four rows
13
+ // collapsed onto the card's centre, their dividers through the text).
14
+ import { translateElement } from "./concept-geometry.mjs";
15
+ import { linePoints, linesWithin, moveLine, fixCopies, translateFigure } from "./repeat-groups.mjs";
16
+
17
+ const half = (v) => Math.round(v * 2) / 2;
18
+ const isText = (e) => (e.kind === "text" || e.kind === "number") && e.text != null;
19
+
20
+ const horizontal = (c) => {
21
+ const pts = linePoints(c);
22
+ if (pts.length < 2) return null;
23
+ const ys = pts.map((p) => p.y);
24
+ if (Math.max(...ys) - Math.min(...ys) > 2) return null;
25
+ return { y: (Math.min(...ys) + Math.max(...ys)) / 2, x0: Math.min(...pts.map((p) => p.x)), x1: Math.max(...pts.map((p) => p.x)) };
26
+ };
27
+
28
+ /**
29
+ * Point the stack's rows at what survived normalization (a text block's lines
30
+ * folded into one element, a label into its shape, a rule into its line); a
31
+ * stack whose row lost every part is dropped with a note. `before`: the
32
+ * elements by id before normalization.
33
+ */
34
+ export function remapStacks(spec, before) {
35
+ if (!spec.stacks?.length) return [];
36
+ const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
37
+ const lines = new Set((spec.connectors ?? []).map((c) => c.id).filter(Boolean));
38
+ const into = new Map();
39
+ for (const e of spec.elements ?? []) for (const p of e.paragraphs ?? []) if (p.id && p.id !== e.id) into.set(p.id, e.id);
40
+ const resolve = (id) => {
41
+ if (byId.has(id) || lines.has(id)) return id;
42
+ if (into.has(id)) return into.get(id);
43
+ const old = before.get(id);
44
+ const words = String(old?.text ?? "").trim();
45
+ const p = old?.parent ? byId.get(old.parent) : null;
46
+ return words && p && String(p.text ?? "").includes(words) ? p.id : null;
47
+ };
48
+ const notes = [];
49
+ spec.stacks = spec.stacks.flatMap((g) => {
50
+ const rows = g.rows.map((row) => [...new Set(row.map(resolve).filter(Boolean))]);
51
+ if (rows.some((r) => !r.length)) {
52
+ notes.push(`${g.id}: a row did not survive normalization — kept as drawn`);
53
+ return [];
54
+ }
55
+ // A part in two rows (a text block folded across them) is no row's part.
56
+ if (new Set(rows.flat()).size < rows.flat().length) {
57
+ notes.push(`${g.id}: a part belongs to two of its rows after normalization — kept as drawn`);
58
+ return [];
59
+ }
60
+ return [{ ...g, rows, parent: g.parent && byId.has(g.parent) ? g.parent : null }];
61
+ });
62
+ return notes;
63
+ }
64
+
65
+ // The parts of a row, as elements and as lines.
66
+ function partsOf(spec, row) {
67
+ const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
68
+ const byLine = new Map((spec.connectors ?? []).filter((c) => c.id).map((c) => [c.id, c]));
69
+ return { elements: row.map((id) => byId.get(id)).filter(Boolean), lines: row.map((id) => byLine.get(id)).filter(Boolean) };
70
+ }
71
+
72
+ // A row's current extent along y, elements by `at`, lines by their points.
73
+ export function rowExtent(spec, row, at) {
74
+ const { elements, lines } = partsOf(spec, row);
75
+ const ys = [
76
+ ...elements.flatMap((e) => { const r = { ...e, ...at(e) }; return [r.y, r.y + r.h]; }),
77
+ ...lines.flatMap((c) => linePoints(c).map((p) => p.y)),
78
+ ];
79
+ return ys.length ? { top: Math.min(...ys), bottom: Math.max(...ys) } : null;
80
+ }
81
+
82
+ /**
83
+ * A block may not span the rows of a declared stack: the row is the finer
84
+ * structure, and a text block folding a title with the bodies of three rows
85
+ * would make one element of three rows (x105 QBR 4: the reader declared both).
86
+ * The texts of such a block keep their block only within one row; a text in
87
+ * no row (the title over the rows) keeps nothing to fold with. Runs before
88
+ * the folds. Returns the ids unblocked.
89
+ */
90
+ export function unblockStackRows(spec) {
91
+ const rowOf = new Map();
92
+ for (const g of spec.stacks ?? []) g.rows.forEach((row, i) => { for (const id of row) rowOf.set(id, `${g.id}:${i}`); });
93
+ if (!rowOf.size) return [];
94
+ const blocks = new Map();
95
+ for (const e of spec.elements ?? []) if (e.block) blocks.set(e.block, [...(blocks.get(e.block) ?? []), e]);
96
+ const out = [];
97
+ for (const members of blocks.values()) {
98
+ const rows = new Set(members.map((e) => rowOf.get(e.id)).filter(Boolean));
99
+ if (rows.size < 2 && !(rows.size === 1 && members.some((e) => !rowOf.has(e.id)))) continue;
100
+ for (const e of members) { delete e.block; out.push(e.id); }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * What bounds the stack in its parent, read once from the painting: the
107
+ * siblings (not parts of the stack) that lie wholly above or below it in its
108
+ * columns, and the dividers inside the parent that are no row's part. Stored
109
+ * on the stack as `band`; measured from where those are NOW at every pass.
110
+ */
111
+ export function recordStackBand(spec, g) {
112
+ const els = spec.elements ?? [];
113
+ const byId = new Map(els.map((e) => [e.id, e]));
114
+ const parts = new Set(g.rows.flat());
115
+ const p = g.parent ? byId.get(g.parent) : null;
116
+ const rows = g.rows.map((r) => rowExtent(spec, r, (o) => o)).filter(Boolean);
117
+ if (!rows.length) return null;
118
+ const top = Math.min(...rows.map((r) => r.top));
119
+ const bottom = Math.max(...rows.map((r) => r.bottom));
120
+ const members = g.rows.flat().map((id) => byId.get(id)).filter(Boolean);
121
+ const x0 = Math.min(...members.map((e) => e.x));
122
+ const x1 = Math.max(...members.map((e) => e.x + e.w));
123
+ const spans = (o) => o.x < x1 && x0 < o.x + o.w;
124
+ const sibs = els.filter((o) => !parts.has(o.id) && o.w > 0 && o.h > 0 && o.kind !== "rule" && (p ? o.parent === p.id : !o.parent) && o !== p);
125
+ // A rule between two paragraphs of one block is that block's (block-dividers.mjs), never a gap's.
126
+ const blockDividers = new Set(els.flatMap((e) => (e.dividers ?? []).map((d) => d.id)));
127
+ const seps = (p ? linesWithin(spec, p) : spec.connectors ?? []).filter((c) => c.id && !parts.has(c.id) && !blockDividers.has(c.id)).map((c) => ({ c, l: horizontal(c) })).filter((x) => x.l && x.l.x0 < x1 && x0 < x.l.x1);
128
+ const band = {
129
+ before: sibs.filter((o) => o.y + o.h <= top + 0.5 && spans(o)).map((o) => o.id),
130
+ after: sibs.filter((o) => o.y >= bottom - 0.5 && spans(o)).map((o) => o.id),
131
+ sepBefore: seps.filter((x) => x.l.y <= top + 0.5).sort((a, b) => b.l.y - a.l.y)[0]?.c.id ?? null,
132
+ sepAfter: seps.filter((x) => x.l.y >= bottom - 0.5).sort((a, b) => a.l.y - b.l.y)[0]?.c.id ?? null,
133
+ // The drawn gaps between rows (spacing "drawn") …
134
+ gaps: rows.slice(1).map((r, i) => r.top - rows[i].bottom),
135
+ lead: 0,
136
+ // … and each listed divider's drawn distance under its row's content, kept
137
+ // at every pass (a text that grew over its divider pushes it down).
138
+ lines: Object.fromEntries(g.rows.flatMap((row) => {
139
+ const { elements, lines } = partsOf(spec, row);
140
+ if (!elements.length) return [];
141
+ const bottom = Math.max(...elements.map((e) => e.y + e.h));
142
+ return lines.map((c) => { const ys = linePoints(c).map((p) => p.y); return [c.id, { lo: Math.min(...ys) - bottom, hi: Math.max(...ys) - bottom }]; });
143
+ })),
144
+ };
145
+ // … and the first row's drawn offset from the band's top.
146
+ band.lead = top - stackBand(spec, { ...g, band }).top;
147
+ // What is drawn BETWEEN two rows (a divider, a down-arrow) and listed with
148
+ // no row belongs to that gap: the rows the reader declared define the gap,
149
+ // and an element of the same parent lying inside it, across the stack's
150
+ // columns, is in it by containment — the same fact as `parent`, no symmetry
151
+ // read into it. It rides with its gap, at its drawn fraction of it (a
152
+ // divider in the middle of the gap stays in the middle).
153
+ const inGap = (y0, y1) => rows.findIndex((r, i) => i < rows.length - 1 && y0 >= r.bottom - 0.5 && y1 <= rows[i + 1].top + 0.5);
154
+ const frac = (i, y0, y1) => { const a = rows[i].bottom, b = rows[i + 1].top; return b > a ? ((y0 + y1) / 2 - a) / (b - a) : 0.5; };
155
+ band.between = [
156
+ ...sibs.filter((o) => spans(o)).map((o) => ({ o, i: inGap(o.y, o.y + o.h) })).filter((x) => x.i >= 0).map((x) => ({ id: x.o.id, line: false, gap: x.i, frac: frac(x.i, x.o.y, x.o.y + x.o.h) })),
157
+ ...(p ? linesWithin(spec, p) : spec.connectors ?? []).filter((c) => c.id && !parts.has(c.id) && !blockDividers.has(c.id)).map((c) => {
158
+ const pts = linePoints(c);
159
+ const xs = pts.map((q) => q.x), ys = pts.map((q) => q.y);
160
+ if (!pts.length || !(Math.min(...xs) < x1 && x0 < Math.max(...xs))) return null;
161
+ const i = inGap(Math.min(...ys), Math.max(...ys));
162
+ return i >= 0 ? { id: c.id, line: true, gap: i, frac: frac(i, Math.min(...ys), Math.max(...ys)) } : null;
163
+ }).filter(Boolean),
164
+ ];
165
+ return band;
166
+ }
167
+
168
+ /** The band the stack owns now: its parent's box cut by what lies beyond the stack. */
169
+ export function stackBand(spec, g, at = (o) => o) {
170
+ const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
171
+ const p = g.parent ? byId.get(g.parent) : null;
172
+ const slot = spec.slot;
173
+ let lo = p ? p.y : 0;
174
+ let hi = p ? p.y + p.h : slot.h;
175
+ const b = g.band;
176
+ if (!b) return { top: lo, bottom: hi };
177
+ const rect = (o) => ({ y: o.y, h: o.h, ...at(o) });
178
+ for (const id of b.before ?? []) { const o = byId.get(id); if (o) lo = Math.max(lo, rect(o).y + rect(o).h); }
179
+ for (const id of b.after ?? []) { const o = byId.get(id); if (o) hi = Math.min(hi, rect(o).y); }
180
+ const line = (id) => (id == null ? null : (spec.connectors ?? []).find((c) => c.id === id) ?? null);
181
+ const la = line(b.sepBefore) ? horizontal(line(b.sepBefore)) : null;
182
+ const lb = line(b.sepAfter) ? horizontal(line(b.sepAfter)) : null;
183
+ if (la) lo = Math.max(lo, la.y);
184
+ if (lb) hi = Math.min(hi, lb.y);
185
+ return hi - lo > 1 ? { top: lo, bottom: hi } : { top: p ? p.y : 0, bottom: p ? p.y + p.h : slot.h };
186
+ }
187
+
188
+ /**
189
+ * Build every declared stack: rows placed in the stack's band — equal gaps and
190
+ * margins (`even`), or the drawn lead and gaps (`drawn`) — each row's parts set
191
+ * on the row's axis by `align` (a line listed with a row moves with it, never
192
+ * re-aligned). `at(o)` gives a text's measured rect; `move(o, dy)` moves one
193
+ * element (the caller's, so a settle pass records the shift). Returns one
194
+ * line per row moved.
195
+ */
196
+ export function applyStacks(spec, { at = (o) => o, move = null } = {}) {
197
+ const els = spec.elements ?? [];
198
+ const byId = new Map(els.map((e) => [e.id, e]));
199
+ const figures = new Set((spec.sketch?.figures ?? []).map((f) => f.id));
200
+ const deltas = new Map();
201
+ const movedCircles = new Set();
202
+ const kids = (id) => els.filter((o) => o.parent === id);
203
+ let moved = 0;
204
+ const shift = (e, dy) => {
205
+ if (!dy) return;
206
+ moved++;
207
+ if (move) move(e, dy);
208
+ else translateElement(e, 0, dy);
209
+ if (figures.has(e.id)) translateFigure(spec.sketch, e.id, 0, dy, deltas, movedCircles);
210
+ for (const k of kids(e.id)) shift(k, dy);
211
+ };
212
+ const changes = [];
213
+ // A declared grid: its member stacks' rows line up, row by row, in one
214
+ // shared layout (row i as tall as the tallest row i across the members, the
215
+ // first member's band and spacing); a divider drawn across the columns in a
216
+ // shared gap is placed once, at its drawn fraction of that gap.
217
+ const shared = new Map(); // stack id → [{ top, h }] from its grid
218
+ for (const grid of spec.grids ?? []) {
219
+ const members = gridStacks(spec, grid);
220
+ if (members.length < 2) continue;
221
+ const plans = members.map((g) => ({ g, rows: rowPlans(spec, g, at) })).filter((p) => p.rows && p.rows.length === grid.rows);
222
+ if (plans.length !== members.length) { changes.push(`${grid.id}: its columns do not all hold ${grid.rows} rows; laid out column by column`); continue; }
223
+ for (const p of plans) p.g.band ??= recordStackBand(spec, p.g);
224
+ // A member's own dividers lie in the GRID's gaps: their fraction is of the
225
+ // shared gap (the rows' union across the columns), so the same divider in
226
+ // every column lands on the same boundary. Recorded once, like the band.
227
+ const union = Array.from({ length: grid.rows }, (_, i) => ({ top: Math.min(...plans.map((p) => p.rows[i].ext.top)), bottom: Math.max(...plans.map((p) => p.rows[i].ext.bottom)) }));
228
+ for (const p of plans) {
229
+ if (p.g.band.gridFracs) continue;
230
+ for (const it of p.g.band.between ?? []) {
231
+ const c = it.line ? (spec.connectors ?? []).find((x) => x.id === it.id) : null;
232
+ const e = it.line ? null : byId.get(it.id);
233
+ const y = c ? (() => { const ys = linePoints(c).map((q) => q.y); return (Math.min(...ys) + Math.max(...ys)) / 2; })() : e ? e.y + e.h / 2 : null;
234
+ const a = union[it.gap]?.bottom, b = union[it.gap + 1]?.top;
235
+ if (y != null && a != null && b != null && b > a) it.frac = Math.min(1, Math.max(0, (y - a) / (b - a)));
236
+ }
237
+ p.g.band.gridFracs = true;
238
+ }
239
+ const lead = plans[0].g;
240
+ const band = stackBand(spec, lead, at);
241
+ const heights = Array.from({ length: grid.rows }, (_, i) => Math.max(...plans.map((p) => p.rows[i].ext.bottom - p.rows[i].ext.top)));
242
+ const total = heights.reduce((a, b) => a + b, 0);
243
+ const len = band.bottom - band.top;
244
+ const even = lead.spacing !== "drawn";
245
+ const gaps = even ? heights.map(() => (len - total) / (heights.length + 1)) : [lead.band.lead, ...lead.band.gaps];
246
+ if (even && gaps[0] < 0) { changes.push(`${grid.id}: its rows need ${Math.ceil(total - len)}px more than its columns hold; laid out column by column`); continue; }
247
+ const tops = [];
248
+ let top = band.top;
249
+ heights.forEach((h, i) => { top += gaps[i] ?? 0; tops.push({ top: half(top), h }); top += h; });
250
+ for (const p of plans) shared.set(p.g.id, tops);
251
+ grid.band ??= recordGridBand(spec, grid, members);
252
+ grid.placed = tops.map((t) => [t.top, t.top + t.h]);
253
+ }
254
+ for (const g of spec.stacks ?? []) {
255
+ if (new Set(g.rows.flat()).size < g.rows.flat().length) continue;
256
+ g.band ??= recordStackBand(spec, g);
257
+ if (!g.band) continue;
258
+ const band = stackBand(spec, g, at);
259
+ // A row: its elements (the content set on the row's axis) and its lines (a
260
+ // divider drawn under the content, which keeps that distance to the
261
+ // content's bottom — so it follows a text that grew).
262
+ const rows = g.rows.map((row) => {
263
+ const parts = partsOf(spec, row);
264
+ const rects = parts.elements.map((e) => ({ ...e, ...at(e) }));
265
+ if (!rects.length && !parts.lines.length) return null;
266
+ const content = rects.length
267
+ ? { top: Math.min(...rects.map((r) => r.y)), bottom: Math.max(...rects.map((r) => r.y + r.h)) }
268
+ : { top: Math.min(...parts.lines.flatMap((c) => linePoints(c).map((p) => p.y))), bottom: Math.max(...parts.lines.flatMap((c) => linePoints(c).map((p) => p.y))) };
269
+ const lineOff = parts.lines.map((c) => { const ys = linePoints(c).map((p) => p.y); const drawn = g.band.lines?.[c.id]; return { c, lo: drawn?.lo ?? Math.min(...ys) - content.bottom, hi: drawn?.hi ?? Math.max(...ys) - content.bottom }; });
270
+ const top = Math.min(content.top, ...lineOff.map((l) => content.bottom + l.lo));
271
+ const bottom = Math.max(content.bottom, ...lineOff.map((l) => content.bottom + l.hi));
272
+ return { ...parts, rects, content, lineOff, ext: { top, bottom }, contentOff: content.top - top };
273
+ }).filter(Boolean);
274
+ if (rows.length < 2) continue;
275
+ const heights = rows.map((r) => r.ext.bottom - r.ext.top);
276
+ const total = heights.reduce((a, b) => a + b, 0);
277
+ const len = band.bottom - band.top;
278
+ const even = g.spacing !== "drawn";
279
+ const gaps = even ? rows.map(() => (len - total) / (rows.length + 1)) : [g.band.lead, ...g.band.gaps];
280
+ if (even && gaps[0] < 0) { changes.push(`${g.id}: its rows need ${Math.ceil(total - len)}px more than ${g.parent ?? "the slide"} holds; left as drawn`); continue; }
281
+ let top = band.top;
282
+ const placed = []; // each row's [top, bottom] after this pass
283
+ const fixed = shared.get(g.id) ?? null;
284
+ rows.forEach((r, i) => {
285
+ top += gaps[i] ?? 0;
286
+ const rowTop = fixed ? fixed[i].top : half(top);
287
+ const rowH = fixed ? fixed[i].h : heights[i];
288
+ // In a grid row taller than this member's own content, the content sits
289
+ // by the stack's `align` within the shared row.
290
+ const slack = rowH - heights[i];
291
+ const contentTop = rowTop + r.contentOff + (g.align === "bottom" ? slack : g.align === "top" ? 0 : slack / 2);
292
+ const contentH = r.content.bottom - r.content.top;
293
+ const before = moved;
294
+ // A row's parts sit in COLUMNS (the icon; the title over its body): parts
295
+ // overlapping in x form one column, stacked as drawn; the column block is
296
+ // what `align` sets on the row's axis, never each part alone (x102 GTM 3:
297
+ // a title and its body both "middle" would land on one line).
298
+ // A part inside another part of the row (the icon on its disc) rides with
299
+ // it: moving both would move it twice.
300
+ const partIds = new Set(r.elements.map((e) => e.id));
301
+ const own = r.elements.filter((e) => !(e.parent && partIds.has(e.parent)));
302
+ const columns = [];
303
+ for (const e of [...own].sort((a, b) => a.y - b.y)) {
304
+ const hit = columns.find((col) => col.some((o) => o.x < e.x + e.w && e.x < o.x + o.w));
305
+ if (hit) hit.push(e); else columns.push([e]);
306
+ }
307
+ for (const col of columns) {
308
+ const rects = col.map((e) => ({ e, ...at(e) }));
309
+ const top0 = Math.min(...rects.map((q) => q.y));
310
+ const gapsIn = rects.slice(1).map((q, k) => q.y - (rects[k].y + rects[k].h));
311
+ const blockH = rects.reduce((n, q) => n + q.h, 0) + gapsIn.reduce((n, v) => n + v, 0);
312
+ const want = g.align === "top" ? contentTop : g.align === "bottom" ? contentTop + contentH - blockH : contentTop + (contentH - blockH) / 2;
313
+ let y = want;
314
+ rects.forEach((q, k) => {
315
+ shift(q.e, half(y - q.y));
316
+ y += q.h + (gapsIn[k] ?? 0);
317
+ });
318
+ void top0;
319
+ }
320
+ for (const { c, lo } of r.lineOff) {
321
+ const was = Math.min(...linePoints(c).map((p) => p.y));
322
+ const dy = half(contentTop + contentH + lo - was);
323
+ if (dy) { moveLine(c, 0, dy); moved++; }
324
+ }
325
+ const dyRow = half(rowTop - r.ext.top);
326
+ if (moved > before) changes.push(`${g.id}: row ${i + 1} ${dyRow >= 0 ? "+" : ""}${dyRow}px, its parts ${g.align === "top" ? "on its top" : g.align === "bottom" ? "on its bottom" : "on its axis"}`);
327
+ placed.push([rowTop, rowTop + rowH]);
328
+ top += rowH;
329
+ });
330
+ // What is drawn in a gap rides with it, at its drawn fraction of the gap.
331
+ placeBetween(g, placed);
332
+ }
333
+ // A grid's own dividers — drawn across its columns in a shared gap — once.
334
+ for (const grid of spec.grids ?? []) if (grid.placed && grid.band) placeBetween(grid, grid.placed);
335
+ fixCopies(spec.sketch, deltas);
336
+ return changes;
337
+
338
+ function placeBetween(owner, placed) {
339
+ for (const it of owner.band.between ?? []) {
340
+ const a = placed[it.gap]?.[1], b = placed[it.gap + 1]?.[0];
341
+ if (a == null || b == null) continue;
342
+ const want = a + (b - a) * it.frac;
343
+ if (it.line) {
344
+ const c = (spec.connectors ?? []).find((x) => x.id === it.id);
345
+ if (!c) continue;
346
+ const ys = linePoints(c).map((q) => q.y);
347
+ const dy = half(want - (Math.min(...ys) + Math.max(...ys)) / 2);
348
+ if (dy) { moveLine(c, 0, dy); moved++; changes.push(`${owner.id}: ${it.id} ${dy >= 0 ? "+" : ""}${dy}px with its gap`); }
349
+ } else {
350
+ const e = byId.get(it.id);
351
+ if (!e) continue;
352
+ const cur = { ...e, ...at(e) };
353
+ const dy = half(want - (cur.y + cur.h / 2));
354
+ if (dy) { shift(e, dy); changes.push(`${owner.id}: ${it.id} ${dy >= 0 ? "+" : ""}${dy}px with its gap`); }
355
+ }
356
+ }
357
+ }
358
+ }
359
+
360
+ // The rows of a stack as applyStacks reads them (extents on the current sizes), or null.
361
+ function rowPlans(spec, g, at) {
362
+ const rows = g.rows.map((row) => rowExtent(spec, row, at) && { ext: rowExtent(spec, row, at) }).filter(Boolean);
363
+ return rows.length >= 2 ? rows : null;
364
+ }
365
+
366
+ /** The member stacks of a grid, in column order: by stack id (a repaired slide's marks) or by the column's card. */
367
+ export function gridStacks(spec, grid) {
368
+ const stacks = spec.stacks ?? [];
369
+ if (grid.stacks?.length) return grid.stacks.map((id) => stacks.find((g) => g.id === id)).filter(Boolean);
370
+ return (grid.columns ?? []).map((col) => stacks.find((g) => g.parent === col) ?? stacks.find((g) => g.rows.flat().includes(col))).filter(Boolean);
371
+ }
372
+
373
+ /**
374
+ * What is drawn across a grid's columns between two of its rows (a divider
375
+ * running through every column), recorded once from the painting: lines
376
+ * spanning two or more member columns whose y lies between the rows' shared
377
+ * extents, with their drawn fraction of that gap.
378
+ */
379
+ export function recordGridBand(spec, grid, members) {
380
+ const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
381
+ const parts = new Set(members.flatMap((g) => g.rows.flat()));
382
+ const own = new Set(members.flatMap((g) => (g.band?.between ?? []).map((it) => it.id)));
383
+ const cols = members.map((g) => { const es = g.rows.flat().map((id) => byId.get(id)).filter(Boolean); return { x0: Math.min(...es.map((e) => e.x)), x1: Math.max(...es.map((e) => e.x + e.w)) }; });
384
+ const rows = Array.from({ length: grid.rows }, (_, i) => {
385
+ const exts = members.map((g) => rowExtent(spec, g.rows[i], (o) => o)).filter(Boolean);
386
+ return { top: Math.min(...exts.map((r) => r.top)), bottom: Math.max(...exts.map((r) => r.bottom)) };
387
+ });
388
+ const between = [];
389
+ for (const c of spec.connectors ?? []) {
390
+ if (!c.id || parts.has(c.id) || own.has(c.id)) continue;
391
+ const h = horizontal(c);
392
+ if (!h || cols.filter((col) => h.x0 < col.x1 && col.x0 < h.x1).length < 2) continue;
393
+ const i = rows.findIndex((r, k) => k < rows.length - 1 && h.y >= r.bottom - 0.5 && h.y <= rows[k + 1].top + 0.5);
394
+ if (i < 0) continue;
395
+ const a = rows[i].bottom, b = rows[i + 1].top;
396
+ between.push({ id: c.id, line: true, gap: i, frac: b > a ? (h.y - a) / (b - a) : 0.5 });
397
+ }
398
+ return { between };
399
+ }
400
+
401
+ /** Point a grid's columns at what survived normalization; a grid short of two columns is dropped with a note. */
402
+ export function remapGrids(spec, before) {
403
+ if (!spec.grids?.length) return [];
404
+ const notes = [];
405
+ const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
406
+ spec.grids = spec.grids.flatMap((grid) => {
407
+ const columns = (grid.columns ?? []).filter((id) => byId.has(id));
408
+ if (!grid.stacks?.length && columns.length < 2) { notes.push(`${grid.id}: fewer than two of its columns survived normalization — laid out column by column`); return []; }
409
+ return [{ ...grid, columns }];
410
+ });
411
+ void before;
412
+ return notes;
413
+ }
@@ -1,11 +1,11 @@
1
1
  // Storyboard-markdown: segment a plan.md into alternating markdown / storyboard
2
2
  // runs, splice an edited storyboard fence back in, and pull the red-thread beats
3
- // out of the `## Red thread` section. Ported 1:1 from the add-in's
4
- // src/ui/src/pages/plan-review/storyboard-markdown.ts (TS types dropped; regexes
5
- // and control flow identical). Ships into the plan gate's browser bundle in one
6
- // shared IIFE with storyboard-schema and the other plan modules, so module-scope
7
- // helpers use an `sm` prefix to stay collision-free; exported names match the
8
- // add-in exactly. The import below is a single line on purpose — the browser
3
+ // out of the `## Red thread` section. Canonical here: the add-in's plan review
4
+ // and runtime import a byte-identical mirror (scripts/sync-transcription-runtime.mjs),
5
+ // typed by src/ui/src/pages/plan-review/storyboard-markdown.ts. Ships into the
6
+ // plan gate's browser bundle in one shared IIFE with storyboard-schema and the
7
+ // other plan modules, so module-scope helpers use an `sm` prefix to stay
8
+ // collision-free. The import below is a single line on purpose — the browser
9
9
  // bundler strips lines matching `import … from "…";`.
10
10
  import { parseStoryboardCanvas, serializeStoryboardCanvas, STORYBOARD_CANVAS_LANG } from "./storyboard-schema.mjs";
11
11
 
@@ -30,8 +30,30 @@ function smCleanRedThreadLine(line) {
30
30
  .trim();
31
31
  }
32
32
 
33
+ /**
34
+ * The authoring contract ends the red thread with one `Frame: cover · closing`
35
+ * (or `Frame: none`) line naming the frame slides. It is a declaration ABOUT the
36
+ * deck, not a move in it, and reading it as one did real damage: it minted a
37
+ * phantom "Frame: none" rail card, and it pushed the move count past the
38
+ * `### Slide N` count, which is the exact condition that turns the whole
39
+ * backbone read-only — no insert, no reorder, no rename.
40
+ */
41
+ const SM_FRAME_LINE_RE = /^Frame\s*:/i;
42
+
43
+ /** The plan's `Frame:` declaration, verbatim, or null when it carries none. */
44
+ export function extractFrameLine(markdown) {
45
+ const section = findRedThreadSection(markdown);
46
+ if (!section) return null;
47
+ for (const raw of markdown.slice(section.bodyStart, section.bodyEnd).split("\n")) {
48
+ const line = smCleanRedThreadLine(raw);
49
+ if (line && SM_FRAME_LINE_RE.test(line)) return line;
50
+ }
51
+ return null;
52
+ }
53
+
33
54
  function smSplitRedThreadBody(body) {
34
- const lines = body.split("\n").map(smCleanRedThreadLine).filter(Boolean);
55
+ const lines = body.split("\n").map(smCleanRedThreadLine).filter(Boolean)
56
+ .filter((line) => !SM_FRAME_LINE_RE.test(line));
35
57
  if (lines.length !== 1) return lines;
36
58
  const [line] = lines;
37
59
  if (!line) return [];
@@ -87,6 +109,11 @@ export function splitPlanMarkdownIntoSegments(markdown) {
87
109
  data: parsed.data,
88
110
  error: parsed.error,
89
111
  warnings: parsed.warnings,
112
+ // The typed issues behind an `error`, when the fence failed on its image
113
+ // uses — `{code, assetId?, useId?, slide?, message, action}` from the
114
+ // image-intent contract. Absent on every other parse failure, so a
115
+ // consumer that ignores it reads exactly as before.
116
+ ...(parsed.issues ? { issues: parsed.issues } : {}),
90
117
  start: matchStart,
91
118
  end: matchEnd,
92
119
  lineOffset,
@@ -117,9 +144,11 @@ export function replaceStoryboardSegment(markdown, segment, nextData) {
117
144
  )}`;
118
145
  }
119
146
 
120
- // Deck-level layout knob: binds EVERY valid storyboard fence to `layout`.
121
- // Fences are spliced last-to-first so earlier offsets stay valid; malformed
122
- // fences are left untouched. Mirrors the add-in's setStoryboardLayoutForAll.
147
+ // Deck-level layout knob: binds EVERY valid body storyboard fence to `layout`.
148
+ // The user picked this in the review, so agent-side hints (`layoutSelection`,
149
+ // canvas geometry) never veto it — only frames stay put. Fences are spliced
150
+ // last-to-first so earlier offsets stay valid; malformed fences are left
151
+ // untouched. Mirrors the add-in's setStoryboardLayoutForAll (b939ea372).
123
152
  export function setStoryboardLayoutForAll(markdown, layout, options = []) {
124
153
  let next = markdown;
125
154
  const target = options.find((option) => option.componentName === layout);
@@ -129,29 +158,43 @@ export function setStoryboardLayoutForAll(markdown, layout, options = []) {
129
158
  for (const segment of boards.reverse()) {
130
159
  if (segment.type !== "storyboard" || !segment.data) continue;
131
160
  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;
161
+ if (data.role === "frame" || data.layout === layout) continue;
138
162
  next = replaceStoryboardSegment(next, segment, { ...data, role: "body", layout,
139
- layoutKey: target.layoutKey, layoutSelection: "automatic", layoutReason: undefined });
163
+ layoutKey: target.layoutKey, layoutSelection: "explicit", layoutReason: undefined });
140
164
  }
141
165
  return next;
142
166
  }
143
167
 
144
- /** Change only verified counterparts; preserve explicit layout choices. */
145
- export function setStoryboardStyle(markdown, tone, options) {
168
+ const SM_FRAME_JOBS = ["cover", "agenda", "divider", "closing", "quote"];
169
+ const smHasCanvas = (option) => Boolean(option?.canvasBounds || option?.contentAreas?.length);
170
+
171
+ // CLI-only (the plan gate's design picker): move every storyboard whose layout
172
+ // the design in `options` lacks onto that design's nearest equivalent. A frame
173
+ // slide keeps its job (cover → the design's first cover layout); a body slide
174
+ // takes the first content layout with as many content areas as its old one
175
+ // (`previous` is the old design's option list), else the first content layout.
176
+ // Options arrive ranked, so "first" is the design's own preference. Boards
177
+ // whose layout the design has, or for which it offers nothing, stay as they are.
178
+ export function remapStoryboardLayouts(markdown, options = [], previous = []) {
179
+ const names = new Set(options.map((option) => option.componentName));
180
+ const content = options.filter((option) => smHasCanvas(option) && (option.job ?? "content") === "content");
146
181
  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 });
182
+ const boards = splitPlanMarkdownIntoSegments(markdown).filter((segment) => segment.type === "storyboard" && segment.data);
183
+ for (const segment of boards.reverse()) {
184
+ const data = segment.data;
185
+ if (!data.layout || names.has(data.layout)) continue;
186
+ const old = previous.find((option) => option.componentName === data.layout) ?? null;
187
+ const job = data.role === "frame" ? data.frameKind : old?.job;
188
+ let target = null;
189
+ if (data.role === "frame" || SM_FRAME_JOBS.includes(job)) {
190
+ target = options.find((option) => option.job === job) ?? null;
191
+ } else {
192
+ const areas = old?.contentAreas?.length ?? 0;
193
+ target = (areas && content.find((option) => (option.contentAreas?.length ?? 0) === areas)) || content[0] || null;
194
+ }
195
+ if (!target) continue;
196
+ next = replaceStoryboardSegment(next, segment, { ...data, layout: target.componentName,
197
+ layoutKey: target.layoutKey, layoutSelection: "automatic", layoutReason: undefined });
155
198
  }
156
199
  return next;
157
200
  }