slidemind 0.3.20 → 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 (185) 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/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,509 @@
1
+ // Line topology: every stroke the reader saw, as ONE Line (docs: the line
2
+ // plan's model). A connector the reader declared, an open path, a rule and a
3
+ // stroked ring all go through the same intake, `toLines(spec)`; a separate
4
+ // arrowhead triangle or end dot at a stroke end is that end's cap.
5
+ //
6
+ // Glue has exactly two sources (I2): the reader declared the end (a
7
+ // connector's from/to, or a stroke end whose `strokeEnds` says its ink
8
+ // touches an element), or — where the reader said nothing about that end —
9
+ // the ink touches the node's outline (LINE_TOL). Nothing is glued for being
10
+ // "near enough": a free end stays where the ink ends.
11
+ //
12
+ // Every open stroke — path or rule, straight or diagonal, headed or not —
13
+ // leaves here as a native line (spec.connectors). Rings stay closed outlines
14
+ // and glyph marks stay drawn forms.
15
+ import { isGlyphStroke, sideOf, strokeTraces, traceMidpoint } from './line-fit.mjs';
16
+ import { illustrationOwner } from './concept-relationships.mjs';
17
+ import { cleanText, textIsPrinted } from './concept-text.mjs';
18
+
19
+ /**
20
+ * Every tolerance the intake uses, in one table. Generic: never tuned per
21
+ * slide or per design.
22
+ */
23
+ export const LINE_TOL = Object.freeze({
24
+ // I2: an end touches a node when its distance to the node's outline is at
25
+ // most max(contactPx, half the stroke width) — the ink's own edge — plus,
26
+ // only where a head is drawn or declared at THAT end, the head's length
27
+ // capped at headReachMaxPx (its tip, not the shaft, meets the node). It is
28
+ // never a proximity reach that grows with the stroke.
29
+ contactPx: 3,
30
+ headReachMaxPx: 12,
31
+ // A head triangle or end dot is an end's cap when its centre lies within
32
+ // capReach · its radius of the end (never less than capFloorPx).
33
+ capReach: 1.5,
34
+ capFloorPx: 5,
35
+ // A filled triangle larger than this is a shape (a roof), not a head…
36
+ headMaxPx: 30,
37
+ // …and a disc larger than this is a node (a timeline stop), not an end dot.
38
+ dotMaxPx: 12,
39
+ // Arrowhead length in stroke widths, DrawingML's sm / med / lg.
40
+ capLen: Object.freeze({ sm: 2, md: 3, lg: 5 }),
41
+ // The width the tolerances assume when the reader gave none. The Line
42
+ // itself carries no width then (null): the design's default weight draws it.
43
+ defaultWidthPx: 2,
44
+ });
45
+
46
+ const NODE_KINDS = new Set(['card', 'panel', 'disc', 'badge', 'shape']);
47
+ const ROUND = new Set(['circle', 'ellipse', 'ring']);
48
+ const SIZES = new Set(['sm', 'md', 'lg']);
49
+ // Caps with a length along the stroke (an oval dot sits centred on the end).
50
+ const HEAD_CAPS = new Set(['triangle', 'arrow', 'stealth', 'diamond']);
51
+ // The path a reader connector's route names.
52
+ const CONNECTOR_KIND = { straight: 'straight', elbow: 'orthogonal', curved: 'curve', arc: 'arc', polyline: 'polyline' };
53
+ const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
54
+ // The reader's no-text stand-ins (":null") are wordless too: the intake runs before
55
+ // normalizeForLayout cleans text, and a placeholder disc must not become a glue target.
56
+ const wordless = e => cleanText(e.text, { printed: textIsPrinted(e) }) == null;
57
+ const noCap = () => ({ type: 'none', size: null, sizePx: 0, colorHex: null, from: null });
58
+ const dashOf = d => (d === 'dashed' ? 'dash' : d === 'dotted' ? 'sysDot' : 'solid');
59
+ const isRound = n => n.kind === 'disc' || ROUND.has(n.shape);
60
+ const holds = (n, r) => r.x >= n.x - 1 && r.y >= n.y - 1 && r.x + r.w <= n.x + n.w + 1 && r.y + r.h <= n.y + n.h + 1;
61
+ const centroid = pts => ({ x: pts.reduce((s, q) => s + q.x, 0) / pts.length, y: pts.reduce((s, q) => s + q.y, 0) / pts.length });
62
+ const finite = p => Number.isFinite(p?.x) && Number.isFinite(p?.y);
63
+
64
+ // The DrawingML size bucket a cap of `px` shows on a stroke of width `w`.
65
+ const sizeBucket = (px, w) => {
66
+ const k = px / Math.max(0.5, w);
67
+ return k <= 2.5 ? 'sm' : k <= 4 ? 'md' : 'lg';
68
+ };
69
+
70
+ function segmentDistance(p, a, b) {
71
+ const dx = b.x - a.x, dy = b.y - a.y, l = dx * dx + dy * dy;
72
+ const t = l ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l)) : 0;
73
+ return Math.hypot(p.x - a.x - t * dx, p.y - a.y - t * dy);
74
+ }
75
+
76
+ /**
77
+ * Distance from `p` to a node's outline (inside or out): its outline polygon
78
+ * when the reader gave one (an irregular panel), an ellipse for a round node,
79
+ * its rect otherwise.
80
+ */
81
+ export function outlineDistance(p, n) {
82
+ const poly = (n.outline ?? []).filter(finite);
83
+ if (poly.length >= 3) return Math.min(...poly.map((a, i) => segmentDistance(p, a, poly[(i + 1) % poly.length])));
84
+ if (isRound(n)) {
85
+ const rx = n.w / 2, ry = n.h / 2, dx = p.x - n.x - rx, dy = p.y - n.y - ry;
86
+ const rho = Math.hypot(dx / rx, dy / ry);
87
+ return rho < 1e-9 ? Math.min(rx, ry) : Math.hypot(dx, dy) * Math.abs(1 - 1 / rho);
88
+ }
89
+ const ox = Math.max(n.x - p.x, 0, p.x - n.x - n.w), oy = Math.max(n.y - p.y, 0, p.y - n.y - n.h);
90
+ if (ox || oy) return Math.hypot(ox, oy);
91
+ return Math.min(p.x - n.x, n.x + n.w - p.x, p.y - n.y, n.y + n.h - p.y);
92
+ }
93
+
94
+ // ---- Path kind: what the stroke's points ARE, never what its glue suggests.
95
+
96
+ function pathKindOf(e, pts) {
97
+ const g = e.geometry;
98
+ if (g?.type === 'arc') return Math.abs(g.sweep) >= 360 ? 'ring' : 'arc';
99
+ if (g?.type === 'spline' || (g?.type === 'path' && (g.commands ?? []).some(c => c?.op === 'C' || c?.op === 'Q'))) return 'curve';
100
+ if (pts.length === 2) return 'straight';
101
+ const axis = pts.slice(1).every((p, i) => Math.abs(p.x - pts[i].x) <= 1 || Math.abs(p.y - pts[i].y) <= 1);
102
+ return axis ? 'orthogonal' : 'polyline';
103
+ }
104
+
105
+ function ringTrace(g) {
106
+ const at = a => ({ x: g.cx + g.rx * Math.cos((a * Math.PI) / 180), y: g.cy + g.ry * Math.sin((a * Math.PI) / 180) });
107
+ return Array.from({ length: 37 }, (_, i) => at(g.start + i * 10));
108
+ }
109
+
110
+ /**
111
+ * A rule's trace: its two points, in the reader's order, when it gave them (a
112
+ * diagonal arrow or an axis stays diagonal); else the centre line of its thin
113
+ * rect, along the long side.
114
+ */
115
+ export function ruleTrace(e) {
116
+ const pts = (e.points ?? []).filter(finite);
117
+ if (pts.length >= 2) return [pts[0], pts.at(-1)];
118
+ return e.w >= e.h
119
+ ? [{ x: e.x, y: e.y + e.h / 2 }, { x: e.x + e.w, y: e.y + e.h / 2 }]
120
+ : [{ x: e.x + e.w / 2, y: e.y }, { x: e.x + e.w / 2, y: e.y + e.h }];
121
+ }
122
+
123
+ function sameTrace(a, b) {
124
+ if (!b || a.length < 3 || a.length !== b.length) return false;
125
+ if (Math.min(dist(a[0], b[0]) + dist(a.at(-1), b.at(-1)), dist(a[0], b.at(-1)) + dist(a.at(-1), b[0])) > 2) return false;
126
+ const remaining = [...b];
127
+ for (const p of a) {
128
+ const i = remaining.findIndex(q => dist(p, q) <= 1);
129
+ if (i < 0) return false;
130
+ remaining.splice(i, 1);
131
+ }
132
+ return true;
133
+ }
134
+
135
+ // The reader's own coordinate inventory of a stroke, for duplicate detection
136
+ // against its connectors (Bezier controls included: order does not matter).
137
+ function inventory(e) {
138
+ const g = e.geometry;
139
+ if (g?.type === 'spline') return g.closed ? null : g.points;
140
+ if (g?.type === 'arc') {
141
+ if (Math.abs(g.sweep) >= 360) return null;
142
+ const at = a => ({ x: g.cx + g.rx * Math.cos(a * Math.PI / 180), y: g.cy + g.ry * Math.sin(a * Math.PI / 180) });
143
+ return [at(g.start), at(g.start + g.sweep / 2), at(g.start + g.sweep)];
144
+ }
145
+ if (g?.type === 'path') {
146
+ const cmds = g.commands ?? [];
147
+ if (cmds.filter(c => c.op === 'M').length !== 1 || cmds.some(c => c.op === 'Z')) return null;
148
+ const pts = [];
149
+ for (const c of cmds) {
150
+ if (c.op === 'C' || c.op === 'Q') pts.push({ x: c.x1, y: c.y1 });
151
+ if (c.op === 'C') pts.push({ x: c.x2, y: c.y2 });
152
+ pts.push({ x: c.x, y: c.y });
153
+ }
154
+ return pts.length >= 2 && pts.every(p => Number.isFinite(p.x) && Number.isFinite(p.y)) ? pts : null;
155
+ }
156
+ return e.points?.length >= 2 ? e.points : null;
157
+ }
158
+
159
+ // A Line restored from the connector `applyLines` wrote for it: the topology
160
+ // as decided then, with whatever has been written on the connector since.
161
+ function restored(c) {
162
+ return {
163
+ ...c.line,
164
+ stroke: { ...c.line.stroke, widthPx: c.strokeWidth ?? c.line.stroke.widthPx, colorHex: c.colorHex ?? c.line.stroke.colorHex },
165
+ path: { ...c.line.path, points: c.points ?? c.line.path.points },
166
+ };
167
+ }
168
+
169
+ /**
170
+ * The cap the reader declared at one end, where no separate head or dot was
171
+ * drawn there: its `strokeEnds` end when it gave one (cap type and length),
172
+ * else a triangle when it said the end is headed, at its sm/md/lg bucket.
173
+ */
174
+ function declaredCap(se, headed, bucket, w) {
175
+ const size = SIZES.has(bucket) ? bucket : null;
176
+ if (se) {
177
+ if (!se.cap || se.cap === 'none') return noCap();
178
+ const px = Number.isFinite(se.capSize) && se.capSize > 0 ? se.capSize : 0;
179
+ return { type: se.cap, size: size ?? (px ? sizeBucket(px, w) : null), sizePx: px || (size ? LINE_TOL.capLen[size] * w : 0), colorHex: null, from: null };
180
+ }
181
+ if (!headed) return noCap();
182
+ return { type: 'triangle', size, sizePx: size ? LINE_TOL.capLen[size] * w : 0, colorHex: null, from: null };
183
+ }
184
+
185
+ // The node ids the reader named as a stroke end anywhere on the slide.
186
+ function declaredEndpoints(elements, connectors) {
187
+ const ids = new Set();
188
+ for (const c of connectors) for (const id of [c.from, c.to]) if (id) ids.add(id);
189
+ for (const x of [...elements, ...connectors]) for (const k of ['start', 'end']) if (x.strokeEnds?.[k]?.element) ids.add(x.strokeEnds[k].element);
190
+ return ids;
191
+ }
192
+
193
+ // The reader often traces an arrowhead's arm into the stroke: the ink runs to
194
+ // the tip, then a few px back along one side of the head. That stub is the
195
+ // head, not the stroke — left in, the head sits on it and points back down
196
+ // (x74N1 cloud 8: a rising trend line ended in a 7px hook with a down-pointing
197
+ // head). The stroke ends at the tip.
198
+ function dropHeadArms(line) {
199
+ for (const i of [0, 1]) {
200
+ const pts = line.path.points;
201
+ if (pts.length < 3 || !HEAD_CAPS.has(line.ends[i].cap.type)) continue;
202
+ const [arm, tip, prev] = i ? [pts.at(-1), pts.at(-2), pts.at(-3)] : [pts[0], pts[1], pts[2]];
203
+ const back = (tip.x - prev.x) * (arm.x - tip.x) + (tip.y - prev.y) * (arm.y - tip.y) < 0;
204
+ if (!back || dist(arm, tip) > LINE_TOL.headReachMaxPx) continue;
205
+ line.path = { ...line.path, points: i ? pts.slice(0, -1) : pts.slice(1) };
206
+ if (line.path.points.length === 2 && (line.path.kind === 'polyline' || line.path.kind === 'orthogonal')) line.path.kind = 'straight';
207
+ const end = line.ends[i];
208
+ if (!end.at || dist(end.at, arm) <= 1) end.at = tip;
209
+ }
210
+ return line;
211
+ }
212
+
213
+ /**
214
+ * Read every Line of a spec. Returns `{ lines, duplicates, converted }`:
215
+ * `duplicates` are strokes that redraw a reader connector (`{ id, of }`),
216
+ * `converted` the element ids `applyLines` replaces by a connector (open
217
+ * strokes and the head triangles they claimed).
218
+ */
219
+ export function readLines(spec) {
220
+ const elements = spec.elements ?? [];
221
+ const connectors = spec.connectors ?? [];
222
+ const byId = new Map(elements.map(e => [e.id, e]));
223
+ const kids = new Map();
224
+ for (const e of elements) if (e.parent) kids.set(e.parent, [...(kids.get(e.parent) ?? []), e]);
225
+ const valid = connectors.filter(c => !c.line && byId.has(c.from) && byId.has(c.to) && c.from !== c.to);
226
+ const heads = elements.filter(e => e.kind === 'path' && wordless(e) && e.fill && e.fill !== 'none' && !e.geometry && e.points?.length === 3 && Math.max(e.w ?? 0, e.h ?? 0) <= LINE_TOL.headMaxPx);
227
+ const dots = elements.filter(e => (e.kind === 'disc' || (e.kind === 'shape' && ROUND.has(e.shape))) && wordless(e) && e.w > 0 && e.h > 0 && Math.max(e.w, e.h) <= LINE_TOL.dotMaxPx && !kids.has(e.id));
228
+ const claimed = new Set();
229
+ // Caps a previous pass already decided stay claimed (idempotence).
230
+ for (const c of connectors) for (const end of c.line?.ends ?? []) if (end.cap?.from) claimed.add(end.cap.from);
231
+
232
+ // Pass 1: the lines drawn as elements.
233
+ const drawn = [];
234
+ const duplicates = [];
235
+ const converted = new Set();
236
+ for (const e of elements) {
237
+ if (illustrationOwner(elements, e) || !wordless(e)) continue;
238
+ let traces = [], source = null;
239
+ if (e.kind === 'rule' && (e.w > 0 || e.h > 0 || e.points?.length >= 2)) { traces = [ruleTrace(e)]; source = 'rule'; }
240
+ else if (e.kind === 'path' && e.geometry?.type === 'arc' && Math.abs(e.geometry.sweep) >= 360 && (!e.fill || e.fill === 'none') && e.stroke) { traces = [ringTrace(e.geometry)]; source = 'ring'; }
241
+ else { traces = strokeTraces(e); source = 'path'; }
242
+ if (!traces.length) continue;
243
+ const mark = source === 'path' && isGlyphStroke(e, traces);
244
+ if (source === 'path' && !mark) {
245
+ const pts = inventory(e);
246
+ const of = pts && valid.find(c => sameTrace(pts, c.points));
247
+ if (of) { duplicates.push({ id: e.id, of }); continue; }
248
+ }
249
+ if (source !== 'ring' && !mark) converted.add(e.id);
250
+ const widthPx = Number.isFinite(e.stroke?.width) && e.stroke.width > 0 ? e.stroke.width : null;
251
+ traces.forEach((t, k) => {
252
+ const outer = [k === 0, k === traces.length - 1];
253
+ drawn.push({
254
+ e, source, mark, outer,
255
+ // The reader's own word on each outer end (contact, cap), when it gave one.
256
+ se: [outer[0] ? e.strokeEnds?.start : null, outer[1] ? e.strokeEnds?.end : null],
257
+ bucket: [outer[0] ? e.ends?.start : null, outer[1] ? e.ends?.end : null],
258
+ line: {
259
+ id: traces.length > 1 ? `${e.id}-${k + 1}` : e.id,
260
+ role: mark ? 'mark' : source === 'rule' ? 'divider' : source === 'ring' ? 'frame' : 'relation',
261
+ source: { kind: source, id: e.id },
262
+ ...(e.loop != null ? { loop: e.loop } : {}),
263
+ ends: [t[0], t.at(-1)].map(at => ({ node: null, at, cap: noCap(), contact: 'none', side: null })),
264
+ path: { kind: source === 'rule' ? pathKindOf({}, t) : pathKindOf(e, t), points: t, corners: 'sharp' },
265
+ stroke: { widthPx, colorHex: e.stroke?.colorHex ?? e.colorHex ?? (source === 'rule' ? e.fillHex : null) ?? null, token: e.stroke?.colorToken ?? e.colorToken ?? null, dash: dashOf(e.stroke?.dash ?? e.dash) },
266
+ read: { color: e.color ?? (source === 'rule' ? 'muted' : null) },
267
+ notes: [],
268
+ },
269
+ });
270
+ });
271
+ }
272
+
273
+ // Caps: each separate head triangle or end dot goes to the NEAREST stroke
274
+ // end within its reach, whichever stroke that is (heads before dots) — a
275
+ // rule listed first never takes a path's head. An end left without one
276
+ // carries the cap the reader declared there.
277
+ const capEnds = drawn.filter(d => !d.mark && d.source !== 'ring').flatMap(d => d.line.ends.map((end, i) => ({ d, end, i })).filter(x => x.d.outer[x.i]));
278
+ const reachOf = el => {
279
+ const c = el.points ? centroid(el.points) : { x: el.x + el.w / 2, y: el.y + el.h / 2 };
280
+ const r = el.points ? Math.max(...el.points.map(q => dist(q, c))) : Math.max(el.w, el.h) / 2;
281
+ return { c, reach: Math.max(LINE_TOL.capFloorPx, r * LINE_TOL.capReach) };
282
+ };
283
+ const pairs = [];
284
+ for (const [rank, list] of [[0, heads], [1, dots]]) {
285
+ for (const el of list) {
286
+ if (claimed.has(el.id)) continue;
287
+ const { c, reach } = reachOf(el);
288
+ for (const ce of capEnds) {
289
+ const d = dist(ce.end.at, c);
290
+ if (d <= reach) pairs.push({ rank, d, el, ce });
291
+ }
292
+ }
293
+ }
294
+ pairs.sort((p, q) => p.rank - q.rank || p.d - q.d);
295
+ const capped = new Set();
296
+ for (const { el, ce } of pairs) {
297
+ if (claimed.has(el.id) || capped.has(ce.end)) continue;
298
+ claimed.add(el.id);
299
+ capped.add(ce.end);
300
+ const w = ce.d.line.stroke.widthPx ?? LINE_TOL.defaultWidthPx;
301
+ const isHead = heads.includes(el);
302
+ const span = k => (isHead ? Math.max(...el.points.map(q => q[k])) - Math.min(...el.points.map(q => q[k])) : el[k === 'x' ? 'w' : 'h']);
303
+ const sizePx = Math.max(span('x'), span('y'));
304
+ const declared = SIZES.has(ce.d.bucket[ce.i]) ? ce.d.bucket[ce.i] : null;
305
+ ce.end.cap = { type: isHead ? 'triangle' : 'oval', size: declared ?? sizeBucket(sizePx, w), sizePx, colorHex: el.colorHex ?? el.fillHex ?? null, from: el.id };
306
+ // A head that leaves with its stroke leaves the elements too.
307
+ if (isHead) converted.add(el.id);
308
+ }
309
+ for (const { d, end, i } of capEnds) {
310
+ if (!capped.has(end)) end.cap = declaredCap(d.se[i], Boolean(d.bucket[i]) && d.bucket[i] !== 'none', d.bucket[i], d.line.stroke.widthPx ?? LINE_TOL.defaultWidthPx);
311
+ }
312
+ for (const d of drawn) if (!d.mark && d.source !== 'ring') dropHeadArms(d.line);
313
+
314
+ // Pass 2: glue. Where the reader said what an end touches, that decides;
315
+ // elsewhere only ink contact with a glue target does. A glue target is a
316
+ // node a line can meaningfully join: one carrying words (its own or a
317
+ // descendant's), or one the reader named as a stroke end somewhere on the
318
+ // slide. A wordless disc, badge or panel is decoration unless declared.
319
+ const gone = new Set([...converted, ...duplicates.map(d => d.id)]);
320
+ const endpoints = declaredEndpoints(elements, connectors);
321
+ const worded = n => !wordless(n) || (kids.get(n.id) ?? []).some(worded);
322
+ const nodes = elements.filter(n => NODE_KINDS.has(n.kind) && n.w > 0 && n.h > 0 && !claimed.has(n.id) && (endpoints.has(n.id) || worded(n)));
323
+ for (const { e, line, mark, source, se } of drawn) {
324
+ if (mark || source === 'ring') continue;
325
+ // A stroke drawn inside a node glues by ink only to nodes inside that same
326
+ // container — never to it or out of it (a refresh mark in a disc beside a hub).
327
+ const box = nodes.filter(n => n.id !== e.id && holds(n, e)).sort((p, q) => p.w * p.h - q.w * q.h)[0];
328
+ const reachable = box ? nodes.filter(n => n !== box && holds(box, n)) : nodes;
329
+ const w = line.stroke.widthPx ?? LINE_TOL.defaultWidthPx;
330
+ const hits = line.ends.map((end, i) => {
331
+ if (se[i]) {
332
+ const id = se[i].element;
333
+ const n = id && id !== e.id && !gone.has(id) ? byId.get(id) : null;
334
+ // A stroke lying wholly inside the element it names divides that
335
+ // element; it does not join it (a row rule ending on its panel's edge
336
+ // was routed to the panel's side site, a loop across the rows).
337
+ return se[i].contact === 'touches' && n && !holds(n, e) ? { n, contact: 'declared' } : null;
338
+ }
339
+ const head = HEAD_CAPS.has(end.cap.type) ? Math.min(end.cap.sizePx || LINE_TOL.capLen.md * w, LINE_TOL.headReachMaxPx) : 0;
340
+ const tol = Math.max(LINE_TOL.contactPx, w / 2) + head;
341
+ const touching = reachable
342
+ .filter(n => n.id !== e.id)
343
+ .map(n => ({ n, d: outlineDistance(end.at, n) }))
344
+ .filter(h => h.d <= tol)
345
+ .sort((p, q) => p.d - q.d || p.n.w * p.n.h - q.n.w * q.n.h);
346
+ return touching[0] ? { n: touching[0].n, contact: 'ink' } : null;
347
+ });
348
+ // Both ends on one node is a mark on it, not a relation between things.
349
+ if (hits[0] && hits[0].n === hits[1]?.n) continue;
350
+ hits.forEach((h, i) => {
351
+ if (!h) return;
352
+ Object.assign(line.ends[i], { node: h.n.id, contact: h.contact, side: sideOf(line.ends[i].at, h.n) });
353
+ });
354
+ // A rule that carries a head or joins a node relates things; a bare one divides.
355
+ if (source === 'rule' && line.ends.some(x => x.node || HEAD_CAPS.has(x.cap.type))) line.role = 'relation';
356
+ }
357
+
358
+ // The reader's connectors: glue as declared — unless its `strokeEnds` says
359
+ // the ink stops short of that element (gap) or joins nothing (free). An end
360
+ // naming a stroke that is itself a line (or a removed duplicate) is a point
361
+ // where it touched it.
362
+ const declared = connectors.map((c, i) => {
363
+ if (c.line) return restored(c);
364
+ const pts = (c.points ?? []).filter(finite);
365
+ const widthPx = Number.isFinite(c.strokeWidth) && c.strokeWidth > 0 ? c.strokeWidth : null;
366
+ const w = widthPx ?? LINE_TOL.defaultWidthPx;
367
+ const arrow = c.arrow ?? 'end';
368
+ const end = (k, id, side, point, at, headed) => {
369
+ const se = c.strokeEnds?.[k];
370
+ const p = point ?? pts.at(at) ?? null;
371
+ const glued = Boolean(id && byId.has(id) && !gone.has(id) && (!se || se.contact === 'touches' || !p));
372
+ return {
373
+ node: glued ? id : null,
374
+ at: p,
375
+ cap: declaredCap(se, headed, c.head, w),
376
+ contact: glued ? 'declared' : 'none',
377
+ side: glued ? side ?? null : null,
378
+ };
379
+ };
380
+ const ends = [
381
+ end('start', c.from, c.fromSide, c.fromPoint, 0, arrow === 'start' || arrow === 'both'),
382
+ end('end', c.to, c.toSide, c.toPoint, -1, arrow === 'end' || arrow === 'both'),
383
+ ];
384
+ return dropHeadArms({
385
+ id: c.id ?? `connector-${i + 1}`,
386
+ role: 'relation',
387
+ source: { kind: 'connector', id: c.id ?? null, index: i },
388
+ ...(c.loop != null ? { loop: c.loop } : {}),
389
+ ends,
390
+ path: { kind: CONNECTOR_KIND[c.route] ?? (pts.length === 2 ? 'straight' : 'polyline'), points: pts, corners: c.corner === 'curve' || c.corner === 'rounded' ? 'rounded' : 'sharp' },
391
+ stroke: { widthPx, colorHex: c.colorHex ?? null, token: null, dash: dashOf(c.style) },
392
+ read: { color: c.color ?? null },
393
+ notes: [],
394
+ });
395
+ });
396
+ return { lines: [...declared, ...drawn.map(d => d.line)], duplicates, converted: [...converted] };
397
+ }
398
+
399
+ /** Every Line of a spec (see `readLines`). */
400
+ export function toLines(spec) {
401
+ return readLines(spec).lines;
402
+ }
403
+
404
+ // The connector's arrow/head words for a Line's caps.
405
+ function arrowOf([s, t]) {
406
+ const arrowed = [s, t].map(end => HEAD_CAPS.has(end.cap.type));
407
+ return {
408
+ arrow: arrowed[0] && arrowed[1] ? 'both' : arrowed[0] ? 'start' : arrowed[1] ? 'end' : 'none',
409
+ head: (arrowed[1] ? t.cap.size : null) ?? (arrowed[0] ? s.cap.size : null),
410
+ };
411
+ }
412
+
413
+ // A Line end in the reader's own `strokeEnds` vocabulary.
414
+ const strokeEndOf = end => ({ element: end.node, contact: end.node ? 'touches' : 'free', cap: end.cap.type, capSize: end.cap.sizePx || null });
415
+
416
+ /**
417
+ * The connector a stroke Line is written as: glued ends name their node, free
418
+ * ends are points, its caps are its `strokeEnds`, and the Line itself rides
419
+ * along (`line`) so the topology is decided once.
420
+ */
421
+ function toConnector(line, slot) {
422
+ const [s, t] = line.ends;
423
+ const glued = Boolean(s.node && t.node);
424
+ const kind = line.path.kind;
425
+ const route = kind === 'straight' ? 'straight' : !glued ? 'arc' : kind === 'arc' || kind === 'curve' ? 'curved' : 'elbow';
426
+ const inSlot = p => ({ x: Math.max(0, Math.min(slot.w, p.x)), y: Math.max(0, Math.min(slot.h, p.y)) });
427
+ return {
428
+ id: line.id,
429
+ from: s.node, to: t.node,
430
+ ...(s.node ? { fromSide: s.side } : { fromPoint: inSlot(s.at) }),
431
+ ...(t.node ? { toSide: t.side } : { toPoint: inSlot(t.at) }),
432
+ route, ...arrowOf(line.ends),
433
+ ...(route === 'arc' ? { via: traceMidpoint(line.path.points) } : {}),
434
+ style: line.stroke.dash === 'solid' ? 'solid' : 'dashed',
435
+ color: line.read.color ?? 'accent', colorHex: line.stroke.colorHex,
436
+ strokeWidth: line.stroke.widthPx,
437
+ strokeEnds: { start: strokeEndOf(s), end: strokeEndOf(t) },
438
+ ...(line.loop != null ? { loop: line.loop } : {}),
439
+ points: line.path.points,
440
+ line,
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Write a spec's stroke Lines into it: each open stroke (path or rule)
446
+ * becomes a connector (glued where the reader declared it or the ink touches
447
+ * a glue target, a point end otherwise), the head triangles it claimed go
448
+ * with it, and a stroke that redraws a reader connector is removed. A reader
449
+ * connector whose `strokeEnds` frees an end gets that end as a point, and its
450
+ * arrow/head follow its caps. Rings and glyph marks stay drawn elements.
451
+ * Idempotent. Returns the notes.
452
+ */
453
+ export function applyLines(spec) {
454
+ const { lines, duplicates, converted } = readLines(spec);
455
+ const elements = spec.elements ?? [];
456
+ const byId = new Map(elements.map(e => [e.id, e]));
457
+ const connectors = spec.connectors ?? [];
458
+ const removed = new Set(converted);
459
+ const notes = [];
460
+ for (const d of duplicates) {
461
+ removed.add(d.id);
462
+ notes.push(`${d.id}: removed duplicate stroke of native connector ${d.of.from} -> ${d.of.to}`);
463
+ }
464
+ const slot = spec.slot;
465
+ const joined = new Map();
466
+ for (const line of lines) {
467
+ if (line.source.kind !== 'connector' || !connectors[line.source.index] || connectors[line.source.index].line) continue;
468
+ const c = connectors[line.source.index];
469
+ line.ends.forEach((end, i) => {
470
+ const [key, pointKey, sideKey] = i ? ['to', 'toPoint', 'toSide'] : ['from', 'fromPoint', 'fromSide'];
471
+ const se = c.strokeEnds?.[i ? 'end' : 'start'];
472
+ if (end.node && !end.at && se && se.contact !== 'touches') notes.push(`${c.id ?? `${c.from} -> ${c.to}`}: the reader saw that end's ink not touch ${c[key]} but gave no point for it; it stays glued`);
473
+ if (!c[key] || end.node || !end.at || !byId.has(c[key]) || removed.has(c[key])) return;
474
+ notes.push(`${c.id ?? `${c.from} -> ${c.to}`}: the reader saw the ink stop short of ${c[key]}; that end is free`);
475
+ c[key] = null;
476
+ c[pointKey] = { x: Math.max(0, Math.min(slot.w, end.at.x)), y: Math.max(0, Math.min(slot.h, end.at.y)) };
477
+ delete c[sideKey];
478
+ });
479
+ if (c.strokeEnds) Object.assign(c, arrowOf(line.ends));
480
+ }
481
+ for (const line of lines) {
482
+ // Only strokes still drawn as elements: a line restored from its
483
+ // connector is already written.
484
+ if ((line.source.kind !== 'path' && line.source.kind !== 'rule') || line.role === 'mark' || !removed.has(line.source.id)) continue;
485
+ connectors.push(toConnector(line, slot));
486
+ const [s, t] = line.ends;
487
+ if (s.node && t.node) joined.set(line.source.id, [...(joined.get(line.source.id) ?? []), `${s.node} -> ${t.node}`]);
488
+ }
489
+ for (const [id, pairs] of joined) notes.push(`${id}: joined stroke into native connector ${pairs.join(', ')}`);
490
+ // A connector the reader attached to a stroke that is now a line keeps its
491
+ // own end where it touched it — a free end, not a dangling id.
492
+ for (const c of connectors) {
493
+ for (const [key, pointKey, at] of [['from', 'fromPoint', 0], ['to', 'toPoint', -1]]) {
494
+ if (!c[key] || !removed.has(c[key]) || !(c.points?.length >= 2)) continue;
495
+ c[pointKey] = c.points.at(at);
496
+ c[key] = null;
497
+ }
498
+ }
499
+ if (removed.size) {
500
+ spec.elements = elements.filter(e => !removed.has(e.id));
501
+ // Preserve labels/children, even when they were attached to a stroke.
502
+ for (const e of spec.elements) if (removed.has(e.parent)) e.parent = byId.get(e.parent)?.parent ?? null;
503
+ }
504
+ spec.connectors = connectors;
505
+ // An arrow is a line with a head: dividers, rules and rails are lines the
506
+ // image draws, not arrows it counts (x74N1 QBR 5: arrows=9 from nine rules).
507
+ if (spec.counts) { spec.counts.elements = spec.elements.length; spec.counts.arrows = connectors.filter(c => (c.arrow ?? 'end') !== 'none').length; }
508
+ return notes;
509
+ }