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,540 @@
1
+ // Repeated organisms, built from one template. The concept image is a sketch:
2
+ // four step cards come out 105, 115, 113 and 115 px tall, their discs and
3
+ // texts a few px apart, the ring 181 px above its hub and 197 px below it.
4
+ // Near-equal geometry is never snapped on a guess (bb782591): the READER
5
+ // declares which containers are one organism and how they are arranged
6
+ // (`spec.repeats`, concept-repeats.ts), and this module builds what it means —
7
+ // - one size for every member (the members' median),
8
+ // - one inner layout: corresponding children at the median offset and size
9
+ // (a disc, its icon, the number badge on the corner, the text block),
10
+ // - exact placement: a row or column on one axis with equal gaps, a grid on
11
+ // equal pitches, a ring on one ellipse around its hub at equal angles —
12
+ // mirror-symmetric about the hub's axes.
13
+ // Pure spec in, spec out; a set that cannot be built stays as drawn, noted.
14
+ import { translateElement } from "./concept-geometry.mjs";
15
+
16
+ const GAP = 4; // px between neighbours when a row or column has to open up
17
+ const ANGLE_SNAP = 15; // degrees: a ring starts on the nearest 15° (top, right…)
18
+
19
+ const median = (v) => {
20
+ const s = [...v].sort((a, b) => a - b);
21
+ const m = s.length >> 1;
22
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
23
+ };
24
+ const centre = (e) => ({ x: e.x + e.w / 2, y: e.y + e.h / 2 });
25
+ const half = (v) => Math.round(v * 2) / 2;
26
+
27
+ /**
28
+ * The declared sets whose members are on this slide: `[{ id, members,
29
+ * arrangement, hub, built }]` with elements. A member is a container (a card),
30
+ * or — a row the design draws without one: its disc, its label, its text — the
31
+ * loose elements that make it, as a virtual member `{ id, ids, virtual }` over
32
+ * their box. `built`: buildRepeats made this set one template.
33
+ */
34
+ export function repeatGroups(spec) {
35
+ const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
36
+ // A part may be a rule that became a line (remapRepeats): it spans the box,
37
+ // so the member's own lines (linesWithin) move with it.
38
+ const lineById = new Map((spec.connectors ?? []).filter((c) => c.id).map((c) => [c.id, c]));
39
+ const out = [];
40
+ for (const g of spec.repeats ?? []) {
41
+ const members = (g.members ?? []).map((m, i) => {
42
+ if (!Array.isArray(m) || m.length === 1) return byId.get(Array.isArray(m) ? m[0] : m);
43
+ const parts = m.filter((id) => byId.has(id)).map((id) => byId.get(id));
44
+ const lines = m.filter((id) => !byId.has(id)).map((id) => lineById.get(id));
45
+ if (!parts.length || lines.some((c) => !c)) return null;
46
+ const pts = [...parts.flatMap((e) => [{ x: e.x, y: e.y }, { x: e.x + e.w, y: e.y + e.h }]), ...lines.flatMap(linePoints)];
47
+ const x = Math.min(...pts.map((p) => p.x));
48
+ const y = Math.min(...pts.map((p) => p.y));
49
+ return { id: `${g.id}-${i + 1}`, ids: parts.map((e) => e.id), virtual: true, kind: "group", parent: null,
50
+ x, y, w: Math.max(...pts.map((p) => p.x)) - x, h: Math.max(...pts.map((p) => p.y)) - y };
51
+ }).filter(Boolean);
52
+ if (members.length < 2) continue;
53
+ out.push({ id: g.id, members, arrangement: g.arrangement, hub: g.hub ? byId.get(g.hub) ?? null : null, built: g.built === true });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * The reader declares its sets on the ids it read; normalization then folds
60
+ * some of them (a title and its body into one block, a label into its shape)
61
+ * and turns rules into lines. A member names what survived: an absorbed id
62
+ * the element that took it in, a rule its line; an id that is gone leaves its
63
+ * member. A set that lost a member, or whose members no longer hold like
64
+ * parts, is dropped: kept as drawn, never built from parts that differ.
65
+ * `before`: the elements by id before normalization. Returns the notes.
66
+ */
67
+ export function remapRepeats(spec, before) {
68
+ if (!spec.repeats?.length) return [];
69
+ const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
70
+ const lines = new Set((spec.connectors ?? []).map((c) => c.id).filter(Boolean));
71
+ const into = new Map();
72
+ 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);
73
+ const resolve = (id) => {
74
+ if (byId.has(id) || lines.has(id)) return id;
75
+ if (into.has(id)) return into.get(id);
76
+ // A label folded into its shape: the shape now carries its words.
77
+ const old = before.get(id);
78
+ const words = String(old?.text ?? "").trim();
79
+ const p = old?.parent ? byId.get(old.parent) : null;
80
+ return words && p && String(p.text ?? "").includes(words) ? p.id : null;
81
+ };
82
+ const kinds = (m) => (Array.isArray(m) ? m : [m]).map((id) => byId.get(id)?.kind ?? "line").sort().join(",");
83
+ const notes = [];
84
+ spec.repeats = spec.repeats.flatMap((g) => {
85
+ let members = g.members.map((m) => (Array.isArray(m) ? [...new Set(m.map(resolve).filter(Boolean))] : resolve(m)));
86
+ if (members.every((m) => Array.isArray(m) && m.length === 1 && byId.has(m[0]))) members = members.map((m) => m[0]);
87
+ const label = `${g.id} (${g.members.map((m) => (Array.isArray(m) ? `(${m.join(" + ")})` : m)).join(", ")})`;
88
+ const why = members.some((m) => !(Array.isArray(m) ? m.some((id) => byId.has(id)) : m)) ? "a member did not survive normalization"
89
+ : new Set(members.map(kinds)).size > 1 ? "its members no longer hold like parts once texts were joined" : null;
90
+ if (why) { notes.push(`${label}: ${why} — kept as drawn`); return []; }
91
+ // Rows of a dot and its line that became the items of one list: the set
92
+ // IS that one text now, built as a list, not as copies of it.
93
+ if (members.length > 1 && new Set(members.map((m) => JSON.stringify(Array.isArray(m) ? [...m].sort() : [m]))).size === 1) {
94
+ notes.push(`${label}: its members became one list`);
95
+ return [];
96
+ }
97
+ return [{ ...g, members }];
98
+ });
99
+ return notes;
100
+ }
101
+
102
+ /** What a member holds: its children (or, a virtual member, its parts) and theirs, by depth. */
103
+ export function descendants(elements, root) {
104
+ const out = [];
105
+ const seen = new Set();
106
+ const walk = (id, depth) => {
107
+ for (const e of elements) if (e.parent === id && !seen.has(e.id)) { seen.add(e.id); out.push({ e, depth }); walk(e.id, depth + 1); }
108
+ };
109
+ if (!root.ids) {
110
+ walk(root.id, 1);
111
+ return out;
112
+ }
113
+ const byId = new Map(elements.map((e) => [e.id, e]));
114
+ const parts = new Set(root.ids);
115
+ // A part inside another part is that part's child, not a second root.
116
+ const nested = (e) => {
117
+ for (let p = e.parent, g = 0; p && g < 32; p = byId.get(p)?.parent ?? null, g++) if (parts.has(p)) return true;
118
+ return false;
119
+ };
120
+ for (const id of root.ids) {
121
+ const e = byId.get(id);
122
+ if (!e || nested(e) || seen.has(id)) continue;
123
+ seen.add(id);
124
+ out.push({ e, depth: 1 });
125
+ walk(id, 2);
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Corresponding children across members: each member's descendants keyed by
132
+ * depth, kind and — a text — its role (a title answers a title, never a
133
+ * body), then in reading order — rows first (children whose centres lie within
134
+ * half the smaller height share a row), left to right inside a row. A key the
135
+ * members share corresponds as far as every member has one; what only some
136
+ * members hold rides along with its member. null when they share nothing.
137
+ */
138
+ export function correspond(elements, members) {
139
+ const reading = (items) => {
140
+ const byY = [...items].sort((a, b) => a.cy - b.cy);
141
+ const rows = [];
142
+ for (const it of byY) {
143
+ const row = rows.at(-1);
144
+ if (row && Math.abs(it.cy - row[0].cy) < Math.min(it.e.h, row[0].e.h) / 2) row.push(it);
145
+ else rows.push([it]);
146
+ }
147
+ return rows.flatMap((r) => r.sort((a, b) => a.cx - b.cx));
148
+ };
149
+ const keyOf = (it) => `${it.depth}:${it.e.kind}${it.e.kind === "text" || it.e.kind === "number" ? `:${it.e.role ?? ""}` : ""}`;
150
+ const lists = members.map((m) => {
151
+ const groups = new Map();
152
+ for (const { e, depth } of descendants(elements, m)) {
153
+ const it = { e, depth, cy: e.y + e.h / 2 - m.y, cx: e.x + e.w / 2 - m.x };
154
+ groups.set(keyOf(it), [...(groups.get(keyOf(it)) ?? []), it]);
155
+ }
156
+ for (const [k, items] of groups) groups.set(k, reading(items));
157
+ return groups;
158
+ });
159
+ const keys = [...lists[0].keys()].filter((k) => lists.every((l) => l.has(k))).sort();
160
+ const counts = keys.map((k) => Math.min(...lists.map((l) => l.get(k).length)));
161
+ const out = lists.map((l) => keys.flatMap((k, j) => l.get(k).slice(0, counts[j]).map((c) => c.e)));
162
+ return out[0].length ? out : null;
163
+ }
164
+
165
+ /**
166
+ * Member centres for an arrangement at template size W×H, from where the
167
+ * members are now. null when the arrangement cannot hold them.
168
+ */
169
+ export function arrange(group, W, H) {
170
+ const { members, arrangement } = group;
171
+ const c = members.map(centre);
172
+ const n = members.length;
173
+ const line = (axis, size) => {
174
+ const cross = axis === "x" ? "y" : "x";
175
+ const order = members.map((_, i) => i).sort((a, b) => c[a][axis] - c[b][axis]);
176
+ const lo = Math.min(...members.map((m) => m[axis]));
177
+ const hi = Math.max(...members.map((m) => m[axis] + (axis === "x" ? m.w : m.h)));
178
+ let gap = (hi - lo - n * size) / (n - 1);
179
+ let start = lo;
180
+ if (gap < GAP) {
181
+ gap = GAP;
182
+ start = (lo + hi) / 2 - (n * size + (n - 1) * GAP) / 2;
183
+ }
184
+ const at = median(c.map((p) => p[cross]));
185
+ const out = new Array(n);
186
+ order.forEach((i, k) => { out[i] = { [axis]: start + size / 2 + k * (size + gap), [cross]: at }; });
187
+ return out;
188
+ };
189
+ if (arrangement === "row") return line("x", W);
190
+ if (arrangement === "column") return line("y", H);
191
+ if (arrangement === "grid") {
192
+ const cluster = (axis, size) => {
193
+ const order = members.map((_, i) => i).sort((a, b) => c[a][axis] - c[b][axis]);
194
+ const bands = [];
195
+ for (const i of order) {
196
+ const last = bands.at(-1);
197
+ if (last && c[i][axis] - c[last[0]][axis] <= size / 2) last.push(i);
198
+ else bands.push([i]);
199
+ }
200
+ const means = bands.map((b) => b.reduce((s, i) => s + c[i][axis], 0) / b.length);
201
+ let pitch = bands.length > 1 ? (means.at(-1) - means[0]) / (bands.length - 1) : 0;
202
+ let first = means[0];
203
+ if (bands.length > 1 && pitch < size + GAP) {
204
+ pitch = size + GAP;
205
+ first = (means[0] + means.at(-1)) / 2 - (pitch * (bands.length - 1)) / 2;
206
+ }
207
+ const at = new Array(n);
208
+ bands.forEach((b, k) => { for (const i of b) at[i] = first + k * pitch; });
209
+ return at;
210
+ };
211
+ const ys = cluster("y", H);
212
+ const xs = cluster("x", W);
213
+ const cells = new Set(members.map((_, i) => `${xs[i]},${ys[i]}`));
214
+ return cells.size === n ? members.map((_, i) => ({ x: xs[i], y: ys[i] })) : null;
215
+ }
216
+ if (arrangement === "ring") {
217
+ const C = group.hub ? centre(group.hub) : { x: c.reduce((s, p) => s + p.x, 0) / n, y: c.reduce((s, p) => s + p.y, 0) / n };
218
+ const deg = (i) => (Math.atan2(c[i].y - C.y, c[i].x - C.x) * 180) / Math.PI;
219
+ const order = members.map((_, i) => i).sort((a, b) => deg(a) - deg(b));
220
+ const step = 360 / n;
221
+ // The start angle every member agrees on (circular mean), on the 15° grid.
222
+ let sx = 0;
223
+ let sy = 0;
224
+ order.forEach((i, k) => {
225
+ const a = ((deg(i) - k * step) * Math.PI) / 180;
226
+ sx += Math.cos(a);
227
+ sy += Math.sin(a);
228
+ });
229
+ const start = Math.round(((Math.atan2(sy, sx) * 180) / Math.PI) / ANGLE_SNAP) * ANGLE_SNAP;
230
+ const phi = new Array(n);
231
+ order.forEach((i, k) => { phi[i] = ((start + k * step) * Math.PI) / 180; });
232
+ // One ellipse through them all (least squares per axis).
233
+ const fit = (axis, f) => {
234
+ const den = members.reduce((s, _, i) => s + f(phi[i]) ** 2, 0);
235
+ return den > 1e-6 ? Math.abs(members.reduce((s, _, i) => s + (c[i][axis] - C[axis]) * f(phi[i]), 0) / den) : 0;
236
+ };
237
+ const rx = fit("x", Math.cos);
238
+ const ry = fit("y", Math.sin);
239
+ const snap = (v) => (Math.abs(v) < 1e-9 ? 0 : v);
240
+ return members.map((_, i) => ({ x: C.x + snap(rx * Math.cos(phi[i])), y: C.y + snap(ry * Math.sin(phi[i])) }));
241
+ }
242
+ return null;
243
+ }
244
+
245
+ const inside = (r, p, tol = 2) => p.x >= r.x - tol && p.y >= r.y - tol && p.x <= r.x + r.w + tol && p.y <= r.y + r.h + tol;
246
+ // Every point a connector carries — its ends, its trace, and the cached native
247
+ // line (`line.ends[].at`, `line.path.points`) the directive is written from.
248
+ // Deduplicated by identity: the trace array is shared between them at times.
249
+ export const linePoints = (c) => [...new Set([c.fromPoint, c.toPoint, ...(c.points ?? []), ...(c.line?.ends ?? []).map((e) => e.at), ...(c.line?.path?.points ?? [])])]
250
+ .filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
251
+
252
+ /**
253
+ * A member's own lines: connectors drawn wholly inside it (a divider under a
254
+ * card title) whose glued ends, if any, are the member or its content. They are
255
+ * not elements, so they move with the member explicitly.
256
+ */
257
+ export function linesWithin(spec, member) {
258
+ const own = new Set([member.id, ...descendants(spec.elements ?? [], member).map(({ e }) => e.id)]);
259
+ return (spec.connectors ?? []).filter((c) => {
260
+ const pts = linePoints(c);
261
+ return pts.length >= 2 && pts.every((p) => inside(member, p)) && [c.from, c.to].every((id) => !id || own.has(id));
262
+ });
263
+ }
264
+
265
+ /** The elements a connector is glued to: its ends, and the cached native line's. */
266
+ export const gluedIds = (c) => [c.from, c.to, ...(c.line?.ends ?? []).map((e) => e?.node)].filter(Boolean);
267
+
268
+ /** A connector's drawn polyline: the cached native path, its trace, or its two ends. */
269
+ export const lineTrace = (c) => [c.line?.path?.points, c.points, [c.fromPoint, c.toPoint]]
270
+ .find((p) => p?.length >= 2 && p.every((q) => Number.isFinite(q?.x) && Number.isFinite(q?.y))) ?? null;
271
+
272
+ /**
273
+ * Where the slide's lines cross the rows y0..y1: `{ x, w, line }` per segment of a
274
+ * connector's trace inside that band, padded by half its stroke. A text grown
275
+ * sideways stops at these as at any neighbour (a loop around a panel, the
276
+ * rule beside a title) — a line is not an element, so an element-only room
277
+ * let a widened text run into it (pm-03 after-bullet-2 over its loop).
278
+ * A line glued to one of `ignore` (the text itself, its card) follows that
279
+ * element when it grows, so it is no neighbour of it.
280
+ */
281
+ export const lineSpans = (spec, y0, y1, ignore = new Set()) => band(spec, y0, y1, ignore, "y", "x").map(({ at, len, line }) => ({ x: at, w: len, line }));
282
+
283
+ /**
284
+ * The same crossing the columns x0..x1: `{ y, h, line }` per segment of a
285
+ * trace there — what lies above or under a text in its own columns (the rule
286
+ * over a title, the timeline over a step number).
287
+ */
288
+ export const lineRows = (spec, x0, x1, ignore = new Set()) => band(spec, x0, x1, ignore, "x", "y").map(({ at, len, line }) => ({ y: at, h: len, line }));
289
+
290
+ // Each segment's extent along `v` where it crosses lo..hi along `u`.
291
+ function band(spec, lo, hi, ignore, u, v) {
292
+ const out = [];
293
+ for (const c of spec.connectors ?? []) {
294
+ const trace = gluedIds(c).some((id) => ignore.has(id)) ? null : lineTrace(c);
295
+ if (!trace) continue;
296
+ const pad = (Number.isFinite(c.strokeWidth) ? c.strokeWidth : 1) / 2;
297
+ for (let i = 1; i < trace.length; i++) {
298
+ const a = trace[i - 1];
299
+ const b = trace[i];
300
+ let t0 = 0;
301
+ let t1 = 1;
302
+ if (a[u] === b[u]) {
303
+ if (a[u] + pad <= lo || a[u] - pad >= hi) continue;
304
+ } else {
305
+ [t0, t1] = [(lo - pad - a[u]) / (b[u] - a[u]), (hi + pad - a[u]) / (b[u] - a[u])].sort((p, q) => p - q);
306
+ t0 = Math.max(0, t0);
307
+ t1 = Math.min(1, t1);
308
+ if (t0 >= t1) continue;
309
+ }
310
+ const va = a[v] + (b[v] - a[v]) * t0;
311
+ const vb = a[v] + (b[v] - a[v]) * t1;
312
+ out.push({ at: Math.min(va, vb) - pad, len: Math.abs(vb - va) + 2 * pad, line: c });
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+
318
+ export function moveLine(c, dx, dy) {
319
+ for (const p of linePoints(c)) { p.x += dx; p.y += dy; }
320
+ }
321
+
322
+ /**
323
+ * A member holding a sketched figure (spec.sketch, absolute coordinates keyed
324
+ * by id) or its own drawn geometry: a drawing moves as a whole, never resized.
325
+ */
326
+ export function holdsDrawing(spec, member) {
327
+ const figures = new Set((spec.sketch?.figures ?? []).map((f) => f.id));
328
+ return [member, ...descendants(spec.elements ?? [], member).map(({ e }) => e)]
329
+ .some((e) => figures.has(e.id) || e.geometry || e.points?.length || e.outline?.length);
330
+ }
331
+
332
+ const isDrawn = (figures, e) => figures.has(e.id) || Boolean(e.geometry || e.points?.length || e.outline?.length);
333
+ // The circles a figure's arcs ride on (an edge names its circle only as an arc).
334
+ const usedCircles = (f) => [...(f.outline ?? []), ...(f.holes ?? []).flat()]
335
+ .filter((p) => p?.next === "arc").map((p) => p.circle).concat(f.copyOf?.about).filter((c) => typeof c === "string" && c !== ".");
336
+
337
+ /**
338
+ * The circles a moved figure rides on must move with it, so they may belong
339
+ * to the drawings of ONE member only: a ring piece in one card arcing on the
340
+ * circle of another, or of a drawing outside the set, pins the set as drawn.
341
+ */
342
+ function circlesShared(sketch, owner) {
343
+ // Only a circle the sketch draws moves; a reader's empty or ":null"
344
+ // stand-in names none (x87 cloud deck: three card sets pinned by ":null").
345
+ const drawn = new Set((sketch?.circles ?? []).map((c) => c.id));
346
+ const users = new Map();
347
+ for (const f of sketch?.figures ?? []) for (const c of usedCircles(f)) if (drawn.has(c)) users.set(c, [...(users.get(c) ?? []), f.id]);
348
+ return [...users.values()].some((ids) => {
349
+ const owners = new Set(ids.map((id) => owner.get(id) ?? null));
350
+ return owners.size > 1 && [...owners].some((o) => o != null);
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Move a sketched figure with its element: its outline, holes, mirror axis and
356
+ * the circles it rides on. A copy is derived from its source, so only its
357
+ * offset changes — after the whole set moved (fixCopies).
358
+ */
359
+ export function translateFigure(sketch, id, dx, dy, deltas, movedCircles) {
360
+ const f = (sketch?.figures ?? []).find((x) => x.id === id);
361
+ if (!f) return;
362
+ deltas.set(id, { dx, dy });
363
+ if (f.copyOf?.id) return;
364
+ for (const p of [...(f.outline ?? []), ...(f.holes ?? []).flat()]) { p.x += dx; p.y += dy; }
365
+ const m = f.mirror;
366
+ if (m?.axis === "vertical") m.at += dx;
367
+ else if (m?.axis === "horizontal") m.at += dy;
368
+ else if (m?.axis === "line" && m.through) {
369
+ if (Array.isArray(m.through)) m.through = [m.through[0] + dx, m.through[1] + dy];
370
+ else m.through = { ...m.through, x: m.through.x + dx, y: m.through.y + dy };
371
+ }
372
+ for (const c of sketch.circles ?? []) {
373
+ if (usedCircles(f).includes(c.id) && !movedCircles.has(c.id)) { c.cx += dx; c.cy += dy; movedCircles.add(c.id); }
374
+ }
375
+ }
376
+
377
+ // A copy sits at its source moved by its offset: it moves by what it should
378
+ // move less what its source moved.
379
+ export function fixCopies(sketch, deltas) {
380
+ for (const f of sketch?.figures ?? []) {
381
+ if (!f.copyOf?.id) continue;
382
+ const own = deltas.get(f.id) ?? { dx: 0, dy: 0 };
383
+ const src = deltas.get(f.copyOf.id) ?? { dx: 0, dy: 0 };
384
+ if (own.dx === src.dx && own.dy === src.dy) continue;
385
+ f.copyOf.dx = (Number(f.copyOf.dx) || 0) + own.dx - src.dx;
386
+ f.copyOf.dy = (Number(f.copyOf.dy) || 0) + own.dy - src.dy;
387
+ }
388
+ }
389
+
390
+ const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
391
+
392
+ /**
393
+ * Keep a built set inside the slot without breaking it. A ring keeps its hub
394
+ * where it is — the hub anchors it to the rest of the slide — and closes in:
395
+ * the members on both sides of the hub move toward it by the same amount.
396
+ * Only when that would run them into the hub does the ring move as one, hub
397
+ * included; a row, column or grid moves as one. Moves the plans in place;
398
+ * returns the hub's shift, or null when the set cannot fit.
399
+ */
400
+ function fitSlot(plans, hub, hubTree, slot) {
401
+ const rects = () => plans.flatMap((p) => [p.box, ...p.content.map((c) => c.to)]);
402
+ const saved = rects().map((r) => ({ ...r }));
403
+ const restore = () => rects().forEach((r, k) => Object.assign(r, saved[k]));
404
+ const move = (p, axis, d) => { for (const r of [p.box, ...p.content.map((c) => c.to)]) r[axis] += d; };
405
+ const axes = [["x", "w", slot.w], ["y", "h", slot.h]];
406
+ const extent = (axis, size, extra = []) => {
407
+ const rs = [...rects(), ...extra];
408
+ return [Math.min(...rs.map((r) => r[axis])), Math.max(...rs.map((r) => r[axis] + r[size]))];
409
+ };
410
+ const closeIn = (axis, size, inward) => {
411
+ const c = hub[axis] + hub[size] / 2;
412
+ for (const p of plans) {
413
+ const pc = p.box[axis] + p.box[size] / 2;
414
+ move(p, axis, pc < c - 1 ? inward : pc > c + 1 ? -inward : 0);
415
+ }
416
+ };
417
+ if (hub) {
418
+ for (const [axis, size, limit] of axes) {
419
+ const c = hub[axis] + hub[size] / 2;
420
+ const [lo, hi] = extent(axis, size);
421
+ const inward = Math.max(0, -lo, hi - limit);
422
+ if (inward > 0 && c - lo > inward && hi - c > inward) closeIn(axis, size, inward);
423
+ }
424
+ const inside = axes.every(([axis, size, limit]) => { const [lo, hi] = extent(axis, size); return lo >= -0.5 && hi <= limit + 0.5; });
425
+ if (inside && plans.every((p) => !overlaps(p.box, hub))) return { dx: 0, dy: 0 };
426
+ restore();
427
+ }
428
+ const out = { dx: 0, dy: 0 };
429
+ for (const [axis, size, limit] of axes) {
430
+ let [lo, hi] = extent(axis, size, hubTree);
431
+ if (hi - lo > limit && hub) {
432
+ closeIn(axis, size, (hi - lo - limit) / 2);
433
+ [lo, hi] = extent(axis, size, hubTree);
434
+ }
435
+ if (hi - lo > limit + 0.5) return null;
436
+ const d = lo < 0 ? -lo : hi > limit ? limit - hi : 0;
437
+ for (const p of plans) move(p, axis, d);
438
+ out[axis === "x" ? "dx" : "dy"] = d;
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /**
444
+ * Build every declared set from one template, in place. Returns one line per
445
+ * set built or kept as drawn.
446
+ */
447
+ export function buildRepeats(spec) {
448
+ const elements = spec.elements ?? [];
449
+ const slot = spec.slot;
450
+ const figures = new Set((spec.sketch?.figures ?? []).map((f) => f.id));
451
+ const drawn = (e) => isDrawn(figures, e);
452
+ const notes = [];
453
+ for (const g of repeatGroups(spec)) {
454
+ const { members } = g;
455
+ const label = `${g.id} (${members.map((m) => (m.ids ? `(${m.ids.join(" + ")})` : m.id)).join(", ")})`;
456
+ // A drawing moves whole with its member (never resized); the circles it
457
+ // rides on move with it, so none may be another member's or the slide's.
458
+ const owner = new Map(members.flatMap((m) => [m, ...descendants(elements, m).map(({ e }) => e)].filter((e) => figures.has(e.id)).map((e) => [e.id, m.id])));
459
+ if (owner.size && circlesShared(spec.sketch, owner)) { notes.push(`${label}: a drawing shares a circle with one outside its member — kept as drawn`); continue; }
460
+ const W = Math.round(median(members.map((m) => m.w)));
461
+ const H = Math.round(median(members.map((m) => m.h)));
462
+ const at = arrange(g, W, H);
463
+ if (!at) { notes.push(`${label}: its ${g.arrangement} could not be laid out — kept as drawn`); continue; }
464
+ const kids = correspond(elements, members);
465
+ // Each member's own lines, in reading order; alike across the set they
466
+ // share one template (a divider at one offset and length), else ride along.
467
+ const lines = members.map((m) => linesWithin(spec, m).sort((a, b) => linePoints(a)[0].y - linePoints(b)[0].y || linePoints(a)[0].x - linePoints(b)[0].x));
468
+ const lineTemplate = lines.every((l) => l.length === lines[0].length) && lines.flat().every((c) => linePoints(c).length === 2 || (c.points?.length === 2 && c.fromPoint && c.toPoint))
469
+ ? lines[0].map((_, k) => ["fromPoint", "toPoint"].map((end) => ({
470
+ x: median(lines.map((l, i) => (l[k][end] ?? l[k].points[end === "fromPoint" ? 0 : 1]).x - members[i].x)),
471
+ y: median(lines.map((l, i) => (l[k][end] ?? l[k].points[end === "fromPoint" ? 0 : 1]).y - members[i].y)),
472
+ })))
473
+ : null;
474
+ const template = kids?.[0].map((_, k) => ({
475
+ x: median(kids.map((l, i) => l[k].x - members[i].x)),
476
+ y: median(kids.map((l, i) => l[k].y - members[i].y)),
477
+ w: median(kids.map((l) => l[k].w)),
478
+ h: median(kids.map((l) => l[k].h)),
479
+ }));
480
+ // Where everything would go, before anything moves. A drawn member keeps
481
+ // its own size about the arranged centre; a drawn child keeps its size
482
+ // about its template centre; what only some members hold rides along.
483
+ const plans = members.map((m, i) => {
484
+ // Half-pixel grid: a hub centred on x.5 keeps its members mirror-exact.
485
+ const [bw, bh] = drawn(m) ? [m.w, m.h] : [W, H];
486
+ const box = { x: half(at[i].x - bw / 2), y: half(at[i].y - bh / 2), w: bw, h: bh };
487
+ const ride = (e) => ({ e, to: { x: half(e.x + box.x - m.x + (bw - m.w) / 2), y: half(e.y + box.y - m.y + (bh - m.h) / 2), w: e.w, h: e.h } });
488
+ const mine = kids?.[i] ?? [];
489
+ const content = [
490
+ ...mine.map((e, k) => {
491
+ const t = template[k];
492
+ return drawn(e)
493
+ ? { e, to: { x: half(box.x + t.x + t.w / 2 - e.w / 2), y: half(box.y + t.y + t.h / 2 - e.h / 2), w: e.w, h: e.h } }
494
+ : { e, to: { x: half(box.x + t.x), y: half(box.y + t.y), w: half(t.w), h: half(t.h) } };
495
+ }),
496
+ ...descendants(elements, m).map(({ e }) => e).filter((e) => !mine.includes(e)).map(ride),
497
+ ];
498
+ return { m, box, content, lines: lines[i] };
499
+ });
500
+ const hub = g.hub ? [g.hub, ...descendants(elements, g.hub).map(({ e }) => e)] : [];
501
+ const shift = fitSlot(plans, g.arrangement === "ring" ? g.hub : null, hub, slot);
502
+ if (!shift) { notes.push(`${label}: one template would leave the slot — kept as drawn`); continue; }
503
+ const moved = new Set(plans.flatMap((p) => [p.m, ...p.content.map((c) => c.e)]));
504
+ const hubAt = hub.map((h) => ({ x: h.x + shift.dx, y: h.y + shift.dy, w: h.w, h: h.h }));
505
+ const collides = plans.some((p, i) => plans.some((q, j) => j > i && overlaps(p.box, q.box)))
506
+ || plans.some((p) => hub.some((h, k) => !moved.has(h) && overlaps(p.box, hubAt[k])));
507
+ if (collides) { notes.push(`${label}: one template would overlap — kept as drawn`); continue; }
508
+ const deltas = new Map();
509
+ const movedCircles = new Set();
510
+ const move = (e, dx, dy) => {
511
+ translateElement(e, dx, dy);
512
+ if (figures.has(e.id)) translateFigure(spec.sketch, e.id, dx, dy, deltas, movedCircles);
513
+ };
514
+ for (const { m, box, content, lines: own } of plans) {
515
+ own.forEach((c, k) => {
516
+ if (!lineTemplate) return moveLine(c, box.x - m.x, box.y - m.y);
517
+ const [a, b] = lineTemplate[k].map((t) => ({ x: half(box.x + t.x), y: half(box.y + t.y) }));
518
+ if (c.fromPoint) c.fromPoint = { ...a };
519
+ if (c.toPoint) c.toPoint = { ...b };
520
+ if (c.points?.length === 2) c.points = [{ ...a }, { ...b }];
521
+ if (c.line?.ends?.length === 2) { c.line.ends[0].at = { ...a }; c.line.ends[1].at = { ...b }; }
522
+ if (c.line?.path?.points?.length === 2) c.line.path.points = [{ ...a }, { ...b }];
523
+ });
524
+ for (const { e, to } of content) {
525
+ move(e, to.x - e.x, to.y - e.y);
526
+ e.w = to.w;
527
+ e.h = to.h;
528
+ }
529
+ move(m, box.x - m.x, box.y - m.y);
530
+ m.w = box.w;
531
+ m.h = box.h;
532
+ }
533
+ if (shift.dx || shift.dy) for (const h of hub) move(h, shift.dx, shift.dy);
534
+ fixCopies(spec.sketch, deltas);
535
+ const declared = (spec.repeats ?? []).find((r) => r.id === g.id);
536
+ if (declared) declared.built = true;
537
+ notes.push(`${label}: one ${W}×${H}px template${kids ? " with one inner layout" : ""}, ${g.arrangement}${g.hub ? ` around ${g.hub.id}` : ""}`);
538
+ }
539
+ return notes;
540
+ }
@@ -286,10 +286,11 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
286
286
  // and the per-layout chrome backgrounds (`assets/chrome-*`). They must be
287
287
  // resolved INDEPENDENTLY of the deck — `assetsDir` above prefers the deck's own
288
288
  // `assets/` (engine downloads), so if `brand.assetsDir` mirrored it, a deck that
289
- // already has any local asset would collapse both layers to the deck dir and
290
- // copyDesignSystem would never copy the bundle's brand assets (chrome included)
291
- // into the preview. Keeping the brand layer on the bundle lets the deck's assets
292
- // layer ON TOP and override by name, without suppressing the brand chrome.
289
+ // already has any local asset would collapse both layers to the deck dir and the
290
+ // preview would lose the bundle's brand assets (chrome included). Keeping the
291
+ // brand layer on the bundle lets the deck's assets layer ON TOP and override by
292
+ // name, without suppressing the brand chrome. build.mjs points the page at this
293
+ // directory directly; nothing is copied into the deck.
293
294
  const brandAssetsDir = firstExistingOr(
294
295
  designRoots.map((r) => path.join(r, "assets")),
295
296
  path.join(designRoots[0], "assets"),