slidemind 0.3.19 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/engine-slides.mjs +1 -1
  16. package/src/bridge/client/render.mjs +27 -1
  17. package/src/bridge/client/transport.mjs +499 -23
  18. package/src/bridge/client.mjs +10 -0
  19. package/src/bridge/config.mjs +67 -6
  20. package/src/bridge/engine-download.mjs +0 -10
  21. package/src/bridge/graph-auth.mjs +55 -3
  22. package/src/bridge/image-intent-envelope.mjs +239 -0
  23. package/src/bridge/package-preview.mjs +67 -0
  24. package/src/bridge/sharepoint.mjs +914 -0
  25. package/src/builders/asset-fetch.mjs +203 -0
  26. package/src/builders/block-dividers.mjs +83 -0
  27. package/src/builders/build.mjs +171 -87
  28. package/src/builders/card-height.mjs +294 -0
  29. package/src/builders/chart-hole.mjs +183 -0
  30. package/src/builders/chart-jsx.mjs +410 -0
  31. package/src/builders/chart-labels.mjs +326 -0
  32. package/src/builders/concept-geometry.mjs +370 -0
  33. package/src/builders/concept-intent.mjs +200 -0
  34. package/src/builders/concept-layout.mjs +79 -0
  35. package/src/builders/concept-lines.mjs +509 -0
  36. package/src/builders/concept-lists.mjs +270 -0
  37. package/src/builders/concept-marks.mjs +84 -0
  38. package/src/builders/concept-relationships.mjs +12 -0
  39. package/src/builders/concept-spec.mjs +645 -0
  40. package/src/builders/concept-text.mjs +140 -0
  41. package/src/builders/concept-waves.mjs +57 -0
  42. package/src/builders/copy-fit.mjs +139 -0
  43. package/src/builders/design-tokens.mjs +347 -0
  44. package/src/builders/draft-check.mjs +552 -340
  45. package/src/builders/draft-lines.mjs +369 -0
  46. package/src/builders/edit-route.mjs +29 -0
  47. package/src/builders/figure-sketch.mjs +375 -0
  48. package/src/builders/fold-sets.mjs +639 -0
  49. package/src/builders/gate-server.mjs +9 -4
  50. package/src/builders/gate-session.js +273 -12
  51. package/src/builders/gate-shell.js +1494 -0
  52. package/src/builders/image-intent-check.mjs +308 -0
  53. package/src/builders/image-use-reconcile.mjs +107 -0
  54. package/src/builders/kit-helpers.mjs +121 -0
  55. package/src/builders/layout-names.mjs +32 -0
  56. package/src/builders/legacy-reference-uses.mjs +179 -0
  57. package/src/builders/line-fit.mjs +224 -0
  58. package/src/builders/line-native.mjs +895 -0
  59. package/src/builders/line-relations.mjs +230 -0
  60. package/src/builders/lint-fix.mjs +117 -9
  61. package/src/builders/lint.mjs +25 -20
  62. package/src/builders/list-jsx.mjs +88 -0
  63. package/src/builders/placement.mjs +133 -0
  64. package/src/builders/plan-checks.mjs +135 -0
  65. package/src/builders/plan-comments.mjs +19 -0
  66. package/src/builders/plan-handlers.mjs +95 -0
  67. package/src/builders/plan-markdown-edit.mjs +3 -3
  68. package/src/builders/plan-overlay.js +1530 -1608
  69. package/src/builders/plan.mjs +689 -186
  70. package/src/builders/preset-definitions.json +1 -0
  71. package/src/builders/preset-eval.mjs +244 -0
  72. package/src/builders/preview.html +51 -120
  73. package/src/builders/red-thread-backbone.mjs +135 -36
  74. package/src/builders/repair.mjs +205 -0
  75. package/src/builders/repeat-groups.mjs +540 -0
  76. package/src/builders/resolve-skill.mjs +5 -4
  77. package/src/builders/review-overlay.js +296 -293
  78. package/src/builders/review.mjs +305 -84
  79. package/src/builders/rule-shift.mjs +96 -0
  80. package/src/builders/seat.mjs +354 -0
  81. package/src/builders/settle-driver.mjs +236 -0
  82. package/src/builders/settle.mjs +1448 -0
  83. package/src/builders/slide-prep.mjs +67 -0
  84. package/src/builders/spec-assets.mjs +236 -0
  85. package/src/builders/spec-match.mjs +56 -0
  86. package/src/builders/stack.mjs +413 -0
  87. package/src/builders/storyboard-markdown.mjs +70 -27
  88. package/src/builders/storyboard-schema.mjs +54 -114
  89. package/src/builders/text-blocks.mjs +212 -0
  90. package/src/builders/text-card-fit.mjs +552 -0
  91. package/src/builders/text-stack.mjs +276 -0
  92. package/src/builders/transcribe.mjs +1069 -0
  93. package/src/cli/assets.mjs +164 -0
  94. package/src/cli/common.mjs +173 -12
  95. package/src/cli/concept-spec-support.mjs +76 -0
  96. package/src/cli/concepts.mjs +1002 -103
  97. package/src/cli/deck.mjs +401 -65
  98. package/src/cli/diagnose.mjs +4 -2
  99. package/src/cli/engine-helpers.mjs +17 -10
  100. package/src/cli/engine-results.mjs +76 -3
  101. package/src/cli/help.mjs +232 -28
  102. package/src/cli/install.mjs +9 -0
  103. package/src/cli/maintenance.mjs +104 -0
  104. package/src/cli/read.mjs +2 -1
  105. package/src/cli/ready.mjs +6 -3
  106. package/src/cli/record.mjs +522 -0
  107. package/src/cli/remote.mjs +818 -0
  108. package/src/cli/revision.mjs +686 -0
  109. package/src/cli/search.mjs +30 -11
  110. package/src/cli/session.mjs +56 -13
  111. package/src/cli/setup.mjs +46 -2
  112. package/src/cli/status.mjs +142 -33
  113. package/src/cli/tasks.mjs +246 -0
  114. package/src/cli/transcribe.mjs +854 -0
  115. package/src/cli/workspace-ui.mjs +260 -0
  116. package/src/cli/workspace.mjs +212 -34
  117. package/src/install-channel.mjs +64 -7
  118. package/src/lib/adopt.mjs +346 -25
  119. package/src/lib/concept-spec-binding.mjs +96 -0
  120. package/src/lib/conversions.mjs +70 -6
  121. package/src/lib/convert-folded.mjs +107 -0
  122. package/src/lib/deck-paths.mjs +418 -0
  123. package/src/lib/durable-ids.mjs +47 -0
  124. package/src/lib/edit-operations.mjs +316 -0
  125. package/src/lib/edit-state.mjs +1244 -0
  126. package/src/lib/edit-transitions.mjs +120 -0
  127. package/src/lib/extensions.mjs +501 -0
  128. package/src/lib/image-assets.mjs +465 -0
  129. package/src/lib/image-derivatives.mjs +234 -0
  130. package/src/lib/image-intent-fingerprint.mjs +70 -0
  131. package/src/lib/image-intent-tags.mjs +176 -0
  132. package/src/lib/image-intent.mjs +802 -0
  133. package/src/lib/image-use-overrides.mjs +279 -0
  134. package/src/lib/inline-components.mjs +613 -0
  135. package/src/lib/lint-converted.mjs +68 -4
  136. package/src/lib/merge-json.mjs +163 -0
  137. package/src/lib/overwrite-guard.mjs +3 -3
  138. package/src/lib/presentation-context.mjs +531 -0
  139. package/src/lib/preservation-tags.mjs +157 -0
  140. package/src/lib/preservation-verdict.mjs +1342 -0
  141. package/src/lib/publish.mjs +1557 -0
  142. package/src/lib/rebase.mjs +945 -0
  143. package/src/lib/reconcile-ack.mjs +4 -6
  144. package/src/lib/reconcile.mjs +62 -24
  145. package/src/lib/record-client.mjs +175 -0
  146. package/src/lib/record-state.mjs +151 -0
  147. package/src/lib/remote.mjs +661 -0
  148. package/src/lib/revision.mjs +1210 -0
  149. package/src/lib/slide-guids.mjs +4 -6
  150. package/src/lib/slide-preservation.mjs +423 -0
  151. package/src/lib/slide-selection.mjs +9 -0
  152. package/src/lib/workspace-transaction.mjs +117 -0
  153. package/src/pptx/content-digest.mjs +334 -0
  154. package/src/pptx/deck-assemble.mjs +1278 -0
  155. package/src/pptx/fill/engine.mjs +2 -3
  156. package/src/pptx/fill/freeform.mjs +6 -3
  157. package/src/pptx/ooxml.mjs +20 -0
  158. package/src/pptx/package-evidence.mjs +383 -0
  159. package/src/pptx/tags.mjs +163 -11
  160. package/src/update-check.mjs +22 -4
  161. package/src/workspace/adapters.mjs +222 -0
  162. package/src/workspace/auth.mjs +256 -0
  163. package/src/workspace/autostart.mjs +204 -0
  164. package/src/workspace/daemon.mjs +360 -0
  165. package/src/workspace/discovery.mjs +180 -0
  166. package/src/workspace/edit.mjs +167 -0
  167. package/src/workspace/events.mjs +52 -0
  168. package/src/workspace/gate-client.mjs +290 -0
  169. package/src/workspace/handback.mjs +73 -0
  170. package/src/workspace/lifecycle.mjs +635 -0
  171. package/src/workspace/operations.mjs +200 -0
  172. package/src/workspace/pick-folder.mjs +152 -0
  173. package/src/workspace/registry.mjs +359 -0
  174. package/src/workspace/remote-links.mjs +397 -0
  175. package/src/workspace/remote-status.mjs +359 -0
  176. package/src/workspace/routes.mjs +1331 -0
  177. package/src/workspace/server.mjs +159 -0
  178. package/src/workspace/sync.mjs +1119 -0
  179. package/src/workspace/task-actions.mjs +215 -0
  180. package/src/workspace/tasks.mjs +507 -0
  181. package/src/workspace/ui/app.js +1886 -0
  182. package/src/workspace/ui/index.html +141 -0
  183. package/src/workspace/ui/plan-bridge.js +120 -0
  184. package/src/workspace/ui/sharepoint.svg +59 -0
  185. package/src/workspace/ui/styles.css +423 -0
  186. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,552 @@
1
+ // Fit a transcribed text inside the card the reader put it in. The card's
2
+ // height is the image's, the type is the design's ramp (transcribe.mjs), so a
3
+ // text often wraps into more lines than the image drew and runs out of its
4
+ // card (09-27 agentic loop: four ring cards, 10–26px each). The trigger is
5
+ // measured containment the reader declared (`parent`), never an image line
6
+ // count. One lever per card per pass — the settle driver re-renders between
7
+ // (after a pass that only moved things it applies the moves to the last
8
+ // render instead), and cards apart from each other advance side by side (the
9
+ // driver has four passes; one slide-wide lever per pass starved the last cards
10
+ // of theirs):
11
+ // 1. widen — the text into its card's free inner width;
12
+ // 1b. rows — a divider a wrapped text runs through moves down with what is
13
+ // under it, only where the card then still holds its content
14
+ // (with its new height, placed in the same pass: both are rigid);
15
+ // 2. disc — the disc beside it one step smaller, the text takes the width;
16
+ // 3. height — one new height, the content re-anchored in it (a centred text
17
+ // re-centres, a top-set one lifts to the inset; card-height.mjs).
18
+ // Then (S) a text past the slot's bottom, or past a card none of those could
19
+ // fit, lifts into the free room above it with its stack and its row; a text
20
+ // on no card first widens into the room beside it.
21
+ // Symmetry first: a card the reader declared part of a repeated set
22
+ // (repeat-groups.mjs) never changes alone — every lever acts on every member
23
+ // alike, and a new height is placed by the set's arrangement (a row keeps its
24
+ // line, a column its gaps, a ring grows about its centres and moves or closes
25
+ // in as one). A lone card with undeclared lookalikes keeps its size and disc:
26
+ // resizing it alone would break a symmetry nobody declared, so that fit is
27
+ // the author's. The type size never changes; a circle keeps its shape.
28
+ import { translateElement } from "./concept-geometry.mjs";
29
+ import { fixedAspect } from "./concept-layout.mjs";
30
+ import { linePoints, lineSpans, linesWithin, moveLine } from "./repeat-groups.mjs";
31
+ import { hardLineCount } from "./settle.mjs";
32
+ import { cardHeight } from "./card-height.mjs";
33
+ import { columnOf, declaredSets, inkOf, isCard, isMarker, isRound, looksLike as alike, markerRoom, stackUnder } from "./text-stack.mjs";
34
+ import { seatFrame, seatOf, seatRect } from "./seat.mjs";
35
+ import { applyStacks } from "./stack.mjs";
36
+ import { placeBlockDividers } from "./block-dividers.mjs";
37
+
38
+ const INSET = 8; // px kept between a text and its card's edge (settle.mjs freeSpan)
39
+ const GAP = 4; // px kept from a neighbour
40
+ const MIN_GAIN = 2; // px of width worth a render pass
41
+ const ESCAPE_PX = 3; // a descender past the edge is no escape (overlap-check.ts)
42
+ const DISC_SCALE = 0.8; // one step, once: a disc is the card's emblem, not padding
43
+ const SLOT_PX = 1; // past the slot's bottom (slot-fit.ts EPSILON_PX)
44
+ const STACK_GAP = 12; // px between a text and the one stacked on it (a title over its body)
45
+
46
+ const filled = (e) => Boolean(e?.fill && e.fill !== "none");
47
+ // A text box; words ON a painted shape (a number badge) belong to that shape.
48
+ const isText = (e) => (e.kind === "text" || e.kind === "number") && e.text != null && !e.textRotation && !filled(e);
49
+ const span = (a0, a1, b0, b1) => a0 < b1 && b0 < a1;
50
+ const hit = (a, b, gap = 0) => span(a.x, a.x + a.w, b.x, b.x + b.w) && span(a.y - gap, a.y + a.h + gap, b.y, b.y + b.h);
51
+
52
+ export function fitTextToCard(spec, measured, { slot = spec.slot, stale = new Set() } = {}) {
53
+ const s = JSON.parse(JSON.stringify(spec));
54
+ const byId = new Map(s.elements.map((e) => [e.id, e]));
55
+ const changes = [];
56
+ const kids = (el) => s.elements.filter((o) => o.parent === el.id);
57
+ const subtree = (el) => [el, ...kids(el).flatMap(subtree)];
58
+ const inTree = (el, root) => {
59
+ for (let p = el, guard = 0; p && guard < 32; p = p.parent ? byId.get(p.parent) : null, guard++) if (p === root) return true;
60
+ return false;
61
+ };
62
+ // Where a text really is: its measured rect (the image's height is a plan),
63
+ // plus what this call moved it by — a rigid move needs no new render.
64
+ const shift = new Map();
65
+ const at = (t) => {
66
+ const m = measured.get(t.id);
67
+ return m?.h > 0 ? { y: m.y != null ? m.y + (shift.get(t.id) ?? 0) : t.y, h: m.h } : { y: t.y, h: t.h };
68
+ };
69
+ const move = (o, dy) => {
70
+ translateElement(o, 0, dy);
71
+ shift.set(o.id, (shift.get(o.id) ?? 0) + dy);
72
+ };
73
+ const heightOf = (o) => (isText(o) ? at(o).h : o.h);
74
+ // What the ink fit re-wrapped in this pass (`stale`) is measured wrong until
75
+ // the next render: nothing here acts on it or on the card it sits on.
76
+ const isStale = (el) => subtree(el).some((o) => stale.has(o.id));
77
+ const img0 = new Map(s.elements.map((e) => [e.id, { x: e.x, y: e.y, w: e.w, h: e.h }]));
78
+ const env = { s, byId, measured, slot, at, img: (o) => img0.get(o.id) ?? o, isText, filled, subtree, inTree };
79
+ const textsOf = (card) => kids(card).filter(isText).sort((a, b) => a.y - b.y || a.x - b.x);
80
+ const block = (card) => {
81
+ const ts = textsOf(card);
82
+ if (!ts.length) return null;
83
+ return { texts: ts, top: Math.min(...ts.map((t) => at(t).y)), bottom: Math.max(...ts.map((t) => at(t).y + at(t).h)) };
84
+ };
85
+ const pastOf = (card) => (block(card)?.bottom ?? -Infinity) - (card.y + card.h);
86
+
87
+ const declared = declaredSets(s);
88
+ const unitOf = (card) => declared.find((g) => g.members.includes(card)) ?? { id: null, members: [card], arrangement: null, hub: null };
89
+ // Symmetry nobody declared is still symmetry: a lone card that looks like
90
+ // its neighbours is never resized on its own (text-stack.mjs looksLike).
91
+ const looksLike = (o, card) => alike(s, o, card);
92
+ const lookalike = (card) => s.elements.some((o) => looksLike(o, card));
93
+ // A divider on the card that a text above it runs through (a title that
94
+ // wrapped into a second line at the design's type size): px to push it down.
95
+ // A divider between two paragraphs of one block is placed by its block (0c),
96
+ // never pushed under the whole block as "a line the text runs through".
97
+ const blockDividers = new Set(s.elements.flatMap((e) => (e.dividers ?? []).map((d) => d.id)));
98
+ const crossingOf = (card) => {
99
+ let best = null;
100
+ for (const c of linesWithin(s, card)) {
101
+ if (c.id && blockDividers.has(c.id)) continue;
102
+ const ys = linePoints(c).map((p) => p.y);
103
+ if (Math.max(...ys) - Math.min(...ys) > 2) continue;
104
+ const ly = ys.reduce((a, b) => a + b, 0) / ys.length;
105
+ const xs = linePoints(c).map((p) => p.x);
106
+ for (const t of textsOf(card)) {
107
+ const r = at(t);
108
+ const need = Math.ceil(r.y + r.h + GAP - ly);
109
+ if (!span(t.x, t.x + t.w, Math.min(...xs), Math.max(...xs))) continue;
110
+ // Measured lines decide: a rule runs through a text only where it
111
+ // crosses one of its lines' ink. A rule on a line's row but past its
112
+ // words is beside them — "Early Signal Review ———" (x113 PM 3): the
113
+ // block's frame is as wide as its body, and the average line height
114
+ // of a tall title over small body lines put the rule "in" the text,
115
+ // so it was pushed under the whole block.
116
+ const m = measured.get(t.id);
117
+ if (m?.lineRects?.length) {
118
+ const dy = r.y - (m.y ?? r.y);
119
+ const dx = t.x - (m.x ?? t.x);
120
+ const hit = m.lineRects.some((l) => ly > l.y + dy + 1 && ly < l.y + dy + l.h - 1 && span(l.x + dx, l.x + dx + l.w, Math.min(...xs), Math.max(...xs)));
121
+ if (hit && (!best || need > best.need)) best = { need, y: ly };
122
+ continue;
123
+ }
124
+ // A rule through the middle of the first line is beside the words
125
+ // ("4. Handoff rule ——"), not under them: pushing the card's content
126
+ // down for it shoved four bodies past the slot (gtm-03). Lower in the
127
+ // line, or under it, the text has run onto its divider.
128
+ const n = measured.get(t.id)?.lines;
129
+ if (n >= 1 && ly > r.y + r.h / n / 4 && ly < r.y + (r.h / n) * 0.75) continue;
130
+ if (r.y < ly - 1 && r.y + r.h > ly + 1 && (!best || need > best.need)) best = { need, y: ly };
131
+ }
132
+ }
133
+ return best;
134
+ };
135
+ // 0a. seat — a text the reader seated in the middle or at the bottom of its
136
+ // carrier sits there by its MEASURED height (seat.mjs): the painting's
137
+ // height was a plan, the ramp's type is taller or shorter. A text its
138
+ // carrier cannot hold within the insets is the levers' below (they grow the
139
+ // card and re-seat its content), not a seat's.
140
+ // Every member of a block follows: an icon's centre stays on its text's
141
+ // line, the lines under a grown text keep their gaps.
142
+ const atRect = (o) => (isText(o) ? { x: o.x, w: o.w, ...at(o) } : o);
143
+ // Only a child whose structure the normaliser recorded (seatBand, seatInset)
144
+ // is re-seated: an inset read off the current position would drift by the
145
+ // measured height on every pass.
146
+ for (const t of s.elements.filter((t) => t.parent && ["middle", "bottom", "span"].includes(seatOf(t)?.y) && !isStale(t) && t.seatBand && t.seatInset && (isText(t) || t.seatBand.y?.block?.length))) {
147
+ const p = byId.get(t.parent);
148
+ if (!p || !filled(p) || !(p.h > 0) || t.w <= 0 || t.h <= 0) continue;
149
+ const r = atRect(t);
150
+ // Its frame: the row between its dividers or neighbours, measured now.
151
+ const f = seatFrame(s, t, p, atRect);
152
+ if (r.h > f.h - 1 || (f.h === p.h && isText(t) && r.h + 2 * INSET > p.h + 0.5)) continue;
153
+ const top = seatRect(t, f, { w: t.w, h: r.h }, { spec: s, at: atRect })?.y;
154
+ const dy = top == null ? 0 : Math.round((top - r.y) * 2) / 2;
155
+ if (!dy) continue;
156
+ for (const o of subtree(t)) move(o, dy);
157
+ changes.push(`${t.id}: ${dy > 0 ? "+" : ""}${dy}px to sit ${seatOf(t).y === "bottom" ? "at the bottom of" : "in the middle of"} ${f.h === p.h ? p.id : `its row in ${p.id}`}`);
158
+ }
159
+ // 0b. stack — a declared stack is laid out again on its rows' MEASURED
160
+ // heights (stack.mjs): even gaps stay even, a divider rides with its row.
161
+ // A stack holding a text the ink fit just re-wrapped waits for the render.
162
+ if (s.stacks?.length) {
163
+ const live = { ...s, stacks: s.stacks.filter((g) => !g.rows.flat().some((id) => stale.has(id))) };
164
+ changes.push(...applyStacks(live, { at: atRect, move: (o, dy) => move(o, dy) }));
165
+ }
166
+ // 0c. block dividers — a rule between two paragraphs of one block sits at
167
+ // that boundary on the measured paragraphs (block-dividers.mjs).
168
+ changes.push(...placeBlockDividers(s, measured, { moveLine }));
169
+ // 0. round — a disc keeps its shape, so a text past its edge fits by the
170
+ // disc's own room: the content (icon and words together) centred in it,
171
+ // else the text as wide as the disc's chord at the rows it would take there.
172
+ // A disc of a declared set, or one with undeclared lookalikes, never changes
173
+ // alone: every member takes the same lift (the one its fullest member needs,
174
+ // their tops kept alike) and its corresponding texts the same chord width,
175
+ // or none does (x47-03: three lookalike step discs, each left spilling).
176
+ const round = new Set();
177
+ for (const d of s.elements.filter((d) => isRound(d) && pastOf(d) > ESCAPE_PX)) {
178
+ if (round.has(d)) continue;
179
+ const u = unitOf(d);
180
+ const group = u.members.length > 1 ? u.members : [d, ...s.elements.filter((o) => looksLike(o, d))];
181
+ for (const g of group) round.add(g);
182
+ if (group.some((g) => !isRound(g) || isStale(g))) continue;
183
+ const content = group.map((g) => {
184
+ const items = kids(g).map((k) => ({ k, top: isText(k) ? at(k).y : k.y, bottom: (isText(k) ? at(k).y : k.y) + heightOf(k) }));
185
+ const top = Math.min(...items.map((it) => it.top));
186
+ const bottom = Math.max(...items.map((it) => it.bottom));
187
+ return { g, items, top, bottom, dy: g.y + g.h / 2 - (top + bottom) / 2 };
188
+ });
189
+ const dy = Math.round(Math.min(...content.map((c) => c.dy)) * 10) / 10;
190
+ if (dy < 0 && content.every((c) => c.top + dy >= c.g.y + INSET - 0.5 && c.bottom + dy <= c.g.y + c.g.h - INSET + 0.5)) {
191
+ for (const c of content) {
192
+ for (const it of c.items) for (const o of subtree(it.k)) move(o, dy);
193
+ changes.push(`${c.g.id}: content ${dy}px to sit inside it${group.length > 1 ? " (its set alike)" : ""}`);
194
+ }
195
+ continue;
196
+ }
197
+ const lists = group.map(textsOf);
198
+ if (lists.some((l) => l.length !== lists[0].length)) continue;
199
+ for (const k of lists[0].keys()) {
200
+ const col = lists.map((l) => l[k]);
201
+ if (col.every((t) => measured.get(t.id)?.lines <= 1)) continue;
202
+ const w = Math.min(...col.map((t, i) => Math.floor(group[i].w * Math.sqrt(Math.max(0, 1 - (at(t).h / group[i].h) ** 2)) - 2 * INSET)));
203
+ // Never narrower than a member already is, and only for a real gain.
204
+ if (col.some((t) => t.w > w) || col.every((t) => w - t.w < MIN_GAIN)) continue;
205
+ col.forEach((t, i) => {
206
+ const g = group[i];
207
+ if (t.w === w) return;
208
+ changes.push(`${t.id}: width ${Math.round(t.w)}→${w}px to fit ${g.id}`);
209
+ t.x = Math.round((g.x + g.w / 2 - w / 2) * 10) / 10;
210
+ t.w = w;
211
+ round.add(t);
212
+ });
213
+ }
214
+ }
215
+
216
+ const units = [];
217
+ const spills = new Set();
218
+ for (const c of s.elements.filter((c) => isCard(c) && !isStale(c))) {
219
+ const spill = pastOf(c) > ESCAPE_PX || crossingOf(c);
220
+ if (!spill && !textsOf(c).some((t) => stackUnder(env, t))) continue;
221
+ const u = unitOf(c);
222
+ if (spill) spills.add(u);
223
+ if (!units.includes(u)) units.push(u);
224
+ }
225
+ const alone = (u) => u.members.length === 1 && lookalike(u.members[0]);
226
+ // Free width beside a text at the rows it takes now, within [left, right]:
227
+ // other elements, and the lines crossing those rows (a card's rules either
228
+ // side of an "OR", a loop around the card).
229
+ const roomBeside = (e, left, right) => {
230
+ const r0 = at(e);
231
+ const others = [
232
+ ...s.elements.filter((o) => !inTree(o, e) && !inTree(e, o) && o.w > 0 && o.h > 0)
233
+ .map((o) => ({ x: o.x, w: o.w, y: isText(o) ? at(o).y : o.y, h: heightOf(o) })),
234
+ ...lineSpans(s, r0.y, r0.y + r0.h, new Set(s.elements.filter((o) => inTree(o, e) || inTree(e, o)).map((o) => o.id)))
235
+ .map((l) => ({ ...l, y: r0.y, h: r0.h })),
236
+ ];
237
+ for (const o of others) {
238
+ if (!span(o.y, o.y + o.h, r0.y, r0.y + r0.h)) continue;
239
+ if (o.x >= e.x + e.w - 1) right = Math.min(right, o.x - GAP);
240
+ else if (o.x + o.w <= e.x + 1) left = Math.max(left, o.x + o.w + GAP);
241
+ }
242
+ return e.align === "center"
243
+ ? e.w + 2 * Math.max(0, Math.min(e.x - left, right - e.x - e.w))
244
+ : e.align === "right" ? e.x + e.w - left : right - e.x;
245
+ };
246
+ const widenTo = (e, w, why) => {
247
+ if (e.align === "center") e.x = Math.round((e.x - (w - e.w) / 2) * 10) / 10;
248
+ else if (e.align === "right") e.x = Math.round((e.x - (w - e.w)) * 10) / 10;
249
+ changes.push(`${e.id}: width ${Math.round(e.w)}→${w}px ${why}`);
250
+ e.w = w;
251
+ };
252
+
253
+ // 1. widen — one width per corresponding text across the set.
254
+ const widenUnit = (u) => {
255
+ const mark = changes.length;
256
+ const lists = u.members.map(textsOf);
257
+ // Corresponding texts across the set share one width; unmatched ones fit alone.
258
+ const sets = lists.every((l) => l.length === lists[0].length)
259
+ ? lists[0].map((_, k) => lists.map((l, i) => [u.members[i], l[k]]))
260
+ : lists.flatMap((l, i) => l.map((t) => [[u.members[i], t]]));
261
+ for (const set of sets) {
262
+ // Only a text that wraps gets shorter by widening (a one-line label does not).
263
+ if (!set.some(([, t]) => !(measured.get(t.id)?.lines <= 1))) continue;
264
+ const w = Math.floor(Math.min(...set.map(([c, t]) => roomBeside(t, c.x + INSET, c.x + c.w - INSET))));
265
+ for (const [card, e] of set) if (w - e.w >= MIN_GAIN) widenTo(e, w, `to fit ${card.id}`);
266
+ }
267
+ return changes.length > mark;
268
+ };
269
+
270
+ // 1b. rows — a divider a text runs through moves down, with everything under
271
+ // it, by the same px on every member (the set keeps one inner layout). The
272
+ // move is rigid, so the new height it may need is placed in the same pass;
273
+ // where the card cannot take that height the divider stays (pushing the
274
+ // content out of the card trades one defect for a worse one, pm-03).
275
+ const dividerUnit = (u) => {
276
+ if (alone(u)) return false;
277
+ const hits = u.members.map(crossingOf);
278
+ const lead = hits.findIndex(Boolean);
279
+ if (lead < 0) return false;
280
+ const d = Math.max(...hits.filter(Boolean).map((h) => h.need));
281
+ const rel = hits[lead].y - u.members[lead].y;
282
+ const plans = u.members.map((card) => {
283
+ const line = linesWithin(s, card).find((c) => linePoints(c).every((p) => Math.abs(p.y - card.y - rel) <= 2));
284
+ if (!line) return null;
285
+ const y = card.y + rel;
286
+ return {
287
+ card,
288
+ items: kids(card).filter((k) => (isText(k) ? at(k).y : k.y) >= y - 1).flatMap(subtree),
289
+ lines: linesWithin(s, card).filter((c) => Math.min(...linePoints(c).map((p) => p.y)) >= y - 1),
290
+ };
291
+ });
292
+ if (plans.some((p) => !p)) return false;
293
+ // Nothing it carries crosses a line or lands on what stays (a full-width
294
+ // line, a text over the card).
295
+ const items = plans.flatMap((p) => p.items);
296
+ if (!clearOf(items, plans.flatMap((p) => p.lines), d) || landsOn(items, d)) return false;
297
+ const push = (dy) => {
298
+ for (const p of plans) {
299
+ for (const o of p.items) move(o, dy);
300
+ for (const c of p.lines) moveLine(c, 0, dy);
301
+ }
302
+ };
303
+ push(d);
304
+ const mark = changes.length;
305
+ if (u.members.some((c) => pastOf(c) > ESCAPE_PX) && !heightUnit(u)) {
306
+ push(-d);
307
+ return false;
308
+ }
309
+ changes.splice(mark, 0, ...plans.map(({ card }) => `${card.id}: content from its divider down +${d}px`));
310
+ return true;
311
+ };
312
+
313
+ // 2. the disc beside the text one step smaller on EVERY member; each text
314
+ // takes the width its disc gave up.
315
+ const discBeside = (card) => {
316
+ const b = block(card);
317
+ if (!b) return null;
318
+ const left = Math.min(...b.texts.map((t) => t.x));
319
+ const right = Math.max(...b.texts.map((t) => t.x + t.w));
320
+ // One disc beside the words: a list of rows with a disc each (cloud-08's
321
+ // four decisions) would lose its symmetry to one smaller disc.
322
+ const beside = kids(card).filter((d) => fixedAspect(d) && filled(d)
323
+ && Math.min(d.y + d.h, b.bottom) - Math.max(d.y, b.top) >= d.h / 2
324
+ && (d.x + d.w <= left + 1 || d.x >= right - 1));
325
+ return beside.length === 1 && !beside[0].fitScale ? beside[0] : null;
326
+ };
327
+ const discUnit = (u) => {
328
+ if (alone(u)) return false;
329
+ const discs = u.members.map(discBeside);
330
+ if (discs.some((d) => !d)) return false;
331
+ u.members.forEach((card, i) => {
332
+ const d = discs[i];
333
+ const onLeft = d.x + d.w <= Math.min(...textsOf(card).map((t) => t.x)) + 1;
334
+ const cx = d.x + d.w / 2;
335
+ const cy = d.y + d.h / 2;
336
+ const w = Math.round(d.w * DISC_SCALE);
337
+ const h = Math.round(d.h * DISC_SCALE);
338
+ const freed = d.w - w;
339
+ const ncx = onLeft ? d.x + w / 2 : d.x + d.w - w / 2;
340
+ for (const o of subtree(d).slice(1)) {
341
+ const ow = o.w * DISC_SCALE;
342
+ const oh = o.h * DISC_SCALE;
343
+ translateElement(o, ncx + (o.x + o.w / 2 - cx) * DISC_SCALE - ow / 2 - o.x, cy + (o.y + o.h / 2 - cy) * DISC_SCALE - oh / 2 - o.y);
344
+ o.w = Math.round(ow);
345
+ o.h = Math.round(oh);
346
+ }
347
+ const was = d.w;
348
+ d.x = Math.round(ncx - w / 2);
349
+ d.y = Math.round(cy - h / 2);
350
+ d.w = w;
351
+ d.h = h;
352
+ d.fitScale = DISC_SCALE;
353
+ for (const t of textsOf(card)) {
354
+ if (onLeft) t.x -= freed;
355
+ t.w += freed;
356
+ }
357
+ changes.push(`${d.id}: ${was}→${w}px, ${textsOf(card).map((t) => t.id).join(", ")} +${freed}px wide`);
358
+ });
359
+ return true;
360
+ };
361
+
362
+ // 3. one height, placed by the set's arrangement, all or nothing (card-height.mjs).
363
+ const { heightUnit, crossesLine } = cardHeight({ s, byId, slot, changes, at, ink: (t) => inkOf(env, t), heightOf, isText, filled, kids, subtree, inTree, move, block, pastOf, alone });
364
+ // 1a. stack — a text grown into what is stacked beneath it (text-stack.mjs):
365
+ // a marker lifts clear of its label where it has the room; else the stack
366
+ // moves down, every member of a set by the same px, into its free room, or
367
+ // with the one new height (3) its card can take; else the text — or the
368
+ // first text of its stack — widens where that saves one of its lines.
369
+ const shiftStacks = (hits, d) => {
370
+ for (const h of hits) {
371
+ for (const o of h.members) move(o, d);
372
+ for (const c of h.lines) moveLine(c, 0, d);
373
+ }
374
+ };
375
+ // Where a moving element is drawn: a text's glyphs (a line through its
376
+ // box's empty margin crosses nothing), anything else its rect.
377
+ const drawn = (o, dy = 0) => {
378
+ if (!isText(o)) return { x: o.x, y: o.y + dy, w: o.w, h: o.h };
379
+ const [x0, x1] = columnOf(env, o);
380
+ const g = inkOf(env, o);
381
+ return { x: x0, y: g.top + dy, w: x1 - x0, h: g.bottom - g.top };
382
+ };
383
+ // Moved by dy, no element crosses a line it did not cross before — member
384
+ // by member: a line between two rows of a stack lies inside its outline.
385
+ const clearOf = (members, lines, dy) => members.every((o) => !(o.w > 0 && o.h > 0) || !crossesLine(drawn(o, dy), [drawn(o)], members, lines));
386
+ // …nor lands on an element that stays and that it did not touch (its own card never counts).
387
+ const landsOn = (members, dy) => members.some((o) => o.w > 0 && o.h > 0 && s.elements.some((f) => !members.includes(f) && !inTree(o, f)
388
+ && f.w > 0 && f.h > 0 && !hit(drawn(f), drawn(o)) && hit(drawn(f), drawn(o, dy))));
389
+ const pushLever = (col, hits, grow) => {
390
+ // The image's gap where the room holds it, else just clear of each other.
391
+ const need = Math.max(...hits.map((h) => h.need));
392
+ const least = Math.max(...hits.map((h) => h.least));
393
+ const up = col.every(isMarker) ? Math.min(...col.map((t) => markerRoom(env, t).up)) : -1;
394
+ const lift = up >= need ? need : least;
395
+ if (up >= least && col.every((t) => clearOf(subtree(t), [], -lift))) {
396
+ for (const [i, t] of col.entries()) {
397
+ for (const o of subtree(t)) move(o, -lift);
398
+ changes.push(`${t.id}: lifted ${lift}px clear of ${hits[i].first.id}`);
399
+ }
400
+ return true;
401
+ }
402
+ const d = hits.every((h) => h.room >= need) || least > Math.min(...hits.map((h) => h.room)) ? need : least;
403
+ const clear = (h) => clearOf(h.members, h.lines, d);
404
+ const notes = hits.map((h) => `${h.first.id}${h.members.length > 1 ? ` and ${h.members.length - 1} under it` : ""}: +${d}px down, clear of ${h.t.id}`);
405
+ if (!hits.every(clear)) return false;
406
+ if (hits.every((h) => h.room >= d)) {
407
+ shiftStacks(hits, d);
408
+ changes.push(...notes);
409
+ return true;
410
+ }
411
+ // Only the card's own bottom is worth a new height: growing it never
412
+ // clears what lies under the stack inside it. The height lever judges
413
+ // what the content lands on from where it was before this push.
414
+ if (!grow || hits.some((h) => h.blocker)) return false;
415
+ shiftStacks(hits, d);
416
+ const mark = changes.length;
417
+ if (grow(new Map(hits.flatMap((h) => h.members.map((o) => [o, d]))))) {
418
+ changes.splice(mark, 0, ...notes);
419
+ return true;
420
+ }
421
+ shiftStacks(hits, -d);
422
+ return false;
423
+ };
424
+ // Only a width that saves a line is worth it: its lines, run full, one fewer.
425
+ const widenLever = (col, bounds, why) => {
426
+ const lines = col.map((t) => measured.get(t.id)?.lines ?? 0);
427
+ if (col.some((t, i) => !(lines[i] > hardLineCount(t)))) return false;
428
+ const w = Math.floor(Math.min(...col.map((t) => roomBeside(t, ...bounds(t)))));
429
+ if (!col.every((t, i) => w - t.w >= MIN_GAIN && w >= (t.w * lines[i]) / (lines[i] - 1))) return false;
430
+ for (const t of col) widenTo(t, w, why);
431
+ return true;
432
+ };
433
+ const inner = (card) => () => [card.x + INSET, card.x + card.w - INSET];
434
+ // Pushes are rigid, so every stack of a card moves in the same pass, top
435
+ // down; a widen changes a text's lines and waits for the next render.
436
+ const stackUnit = (u) => {
437
+ const lists = u.members.map(textsOf);
438
+ if (lists.some((l) => l.length !== lists[0].length)) return false;
439
+ let acted = false;
440
+ for (const k of lists[0].keys()) {
441
+ const col = lists.map((l) => l[k]);
442
+ const found = col.map((t) => stackUnder(env, t));
443
+ if (!found.some(Boolean)) continue;
444
+ // A set moves alike: the members t does not reach move by the same px.
445
+ const hits = col.map((t, i) => found[i] ?? stackUnder(env, t, { force: true }));
446
+ if (hits.some((h) => !h || h.members.some(isStale))) continue;
447
+ if (pushLever(col, hits, alone(u) ? null : (pushed) => heightUnit(u, pushed))) {
448
+ acted = true;
449
+ continue;
450
+ }
451
+ const firsts = hits.map((h) => h.first);
452
+ if (widenLever(col, (t) => inner(byId.get(t.parent))(), `to clear ${firsts[0].id}`)) return true;
453
+ if (firsts.every(isText) && widenLever(firsts, (t) => inner(byId.get(t.parent))(), `to make room under ${col[0].id}`)) return true;
454
+ }
455
+ return acted;
456
+ };
457
+
458
+ const touched = new Set();
459
+ for (const u of units) {
460
+ const levers = spills.has(u) ? [widenUnit, dividerUnit, stackUnit, discUnit, heightUnit] : [stackUnit];
461
+ if (levers.some((lever) => lever(u))) for (const c of u.members) touched.add(c);
462
+ }
463
+
464
+ // S. slot — a text that ends past the slot's bottom, or past a card no lever
465
+ // above could fit, lifts into the free room above it: with the texts stacked
466
+ // on it (a title over its body) and the texts beside it on the same top (a
467
+ // row of labels stays one row), never onto a neighbour or across a line. A
468
+ // text on no card first widens into the room beside it where that saves a
469
+ // line (the slot's edges, its neighbours, a panel it lies on).
470
+ const cardOf = (t) => {
471
+ for (let p = t.parent ? byId.get(t.parent) : null, g = 0; p && g < 32; p = p.parent ? byId.get(p.parent) : null, g++) if (filled(p)) return p;
472
+ return null;
473
+ };
474
+ const bottomOf = (t) => at(t).y + at(t).h;
475
+ const pastBy = (t) => Math.max(bottomOf(t) - slot.h, cardOf(t) ? bottomOf(t) - cardOf(t).y - cardOf(t).h : -Infinity);
476
+ const flagged = (t) => bottomOf(t) - slot.h > SLOT_PX || (cardOf(t) && bottomOf(t) - cardOf(t).y - cardOf(t).h > ESCAPE_PX);
477
+ const stackOf = (t) => {
478
+ const stack = [t];
479
+ for (let top = t; top;) {
480
+ top = s.elements.filter((o) => isText(o) && !stack.includes(o) && o.parent === t.parent && span(o.x, o.x + o.w, top.x, top.x + top.w)
481
+ && at(o).y < at(top).y && at(top).y - (at(o).y + at(o).h) <= STACK_GAP).sort((a, b) => at(b).y - at(a).y)[0];
482
+ if (top) stack.push(top);
483
+ }
484
+ const x0 = Math.min(...stack.map((o) => o.x));
485
+ const x1 = Math.max(...stack.map((o) => o.x + o.w));
486
+ const top = Math.min(...stack.map((o) => at(o).y));
487
+ const card = cardOf(t);
488
+ let ceil = card ? card.y + INSET : 0;
489
+ for (const o of s.elements) {
490
+ if (stack.some((k) => inTree(o, k) || inTree(k, o)) || !(o.w > 0 && o.h > 0) || !span(o.x, o.x + o.w, x0, x1)) continue;
491
+ const oy = isText(o) ? at(o).y : o.y;
492
+ if (oy + heightOf(o) <= top + 1) ceil = Math.max(ceil, oy + heightOf(o) + GAP);
493
+ }
494
+ return { stack, room: Math.floor(top - ceil) };
495
+ };
496
+ // The width a text on no card may take: the slot, or the panel it lies on.
497
+ const panelBounds = (t) => {
498
+ const r = at(t);
499
+ let left = 0;
500
+ let right = slot.w;
501
+ for (const p of s.elements) {
502
+ if (!filled(p) || inTree(t, p) || !hit(p, { x: t.x, y: r.y, w: t.w, h: r.h }) || p.x > t.x || p.x + p.w < t.x + t.w) continue;
503
+ left = Math.max(left, p.x + INSET);
504
+ right = Math.min(right, p.x + p.w - INSET);
505
+ }
506
+ return [left, right];
507
+ };
508
+ // 1a on no card: the same for texts on the slide itself (a step's number
509
+ // over its label, a title over its body), top-down; a push is rigid, so a
510
+ // text it moved is measured where it now is, a widened one waits.
511
+ const widened = new Set();
512
+ for (const t of s.elements.filter((t) => isText(t) && !cardOf(t) && !isStale(t)).sort((a, b) => at(a).y - at(b).y)) {
513
+ if (widened.has(t)) continue;
514
+ const h = stackUnder(env, t);
515
+ if (!h || h.members.some((o) => isStale(o) || widened.has(o)) || pushLever([t], [h], null)) continue;
516
+ if (widenLever([t], panelBounds, `to clear ${h.first.id}`)) widened.add(t);
517
+ else if (isText(h.first) && widenLever([h.first], panelBounds, `to make room under ${t.id}`)) widened.add(h.first);
518
+ }
519
+
520
+ const done = new Set();
521
+ for (const t of s.elements.filter((t) => isText(t) && flagged(t) && !isStale(t) && !widened.has(t))) {
522
+ // A disc is the round lever's (0): lifted in it, a line leaves its outline.
523
+ // A card of a declared set, or one with undeclared lookalikes, changes
524
+ // with the others or not at all: a lift in one member alone breaks its
525
+ // inner layout (alone() guards the card levers the same way).
526
+ const card = cardOf(t);
527
+ if (done.has(t) || touched.has(card) || fixedAspect(card ?? {}) || (card && isStale(card))
528
+ || (card && (unitOf(card).members.length > 1 || (isCard(card) && lookalike(card))))) continue;
529
+ const lines = measured.get(t.id)?.lines;
530
+ if (!cardOf(t) && lines > hardLineCount(t)) {
531
+ // Only a width that saves a line is worth it: its lines, run full, one fewer.
532
+ const w = Math.floor(roomBeside(t, ...panelBounds(t)));
533
+ if (w - t.w >= MIN_GAIN && w >= (t.w * lines) / (lines - 1)) {
534
+ widenTo(t, w, "to end inside the slot");
535
+ done.add(t);
536
+ continue;
537
+ }
538
+ }
539
+ const row = s.elements.filter((o) => isText(o) && !done.has(o) && o.parent === t.parent && Math.abs(at(o).y - at(t).y) <= 1);
540
+ for (const o of row) done.add(o);
541
+ const stacks = row.map(stackOf);
542
+ // As far as the whole row can go, and only when that brings at least one
543
+ // of its texts inside: a lift that fixes nothing is only a move.
544
+ const d = Math.min(Math.ceil(Math.max(...row.map(pastBy))), ...stacks.map((k) => k.room));
545
+ if (!(d >= 1) || !row.some((o) => flagged(o) && pastBy(o) <= d)) continue;
546
+ const lifted = [...new Set(stacks.flatMap((k) => k.stack))];
547
+ if (lifted.some((o) => crossesLine({ x: o.x, y: at(o).y - d, w: o.w, h: at(o).h }, [{ x: o.x, y: at(o).y, w: o.w, h: at(o).h }], []))) continue;
548
+ for (const o of lifted) for (const k of subtree(o)) move(k, -d);
549
+ changes.push(`${lifted.map((o) => o.id).join(", ")}: lifted ${d}px to end inside ${cardOf(t)?.id ?? "the slot"}`);
550
+ }
551
+ return { spec: s, changes };
552
+ }