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
@@ -1,33 +1,26 @@
1
- // Storyboard-canvas schema: the defensive parser/serializer for the JSON block a
2
- // plan.md carries inside each ```storyboard-canvas``` fence. Ported 1:1 from the
3
- // add-in's src/ui/src/pages/plan-review/storyboard-schema.ts (TS types dropped;
4
- // every constant, regex, clamp and branch identical). This module also ships into
5
- // the plan gate's browser bundle, concatenated with the other plan modules into a
6
- // single IIFE — so module-scope helpers use an `sb`/`SB_` prefix to stay unique
7
- // across that shared scope, while the exported API names match the add-in exactly.
1
+ // The per-slide plan record: the defensive parser/serializer for the JSON block a
2
+ // plan.md carries inside each ```storyboard-canvas``` fence. It holds the three
3
+ // things a human signs off — the story beat, the content points, the layout —
4
+ // plus the agent's own bookkeeping (role, sequence link, layout provenance,
5
+ // image direction) and — since the image-intent contract — the slide's
6
+ // `assetUses`, the approved obligations for the deck's imported images
7
+ // (src/lib/image-intent.mjs). It deliberately holds NO design input: no blocks, no
8
+ // geometry, no design-element names. Composition is decided by the concept
9
+ // generator from the story, the content and the layout's own content region,
10
+ // not guessed at plan time in a coordinate space nobody reviewed. The fence
11
+ // keeps its name so every producer and consumer reads unchanged.
12
+ // Canonical here;
13
+ // the add-in imports a byte-identical mirror (scripts/sync-transcription-runtime.mjs),
14
+ // typed by its src/ui/src/pages/plan-review/storyboard-schema.ts. This module also
15
+ // ships into the plan gate's browser bundle, concatenated with the other plan
16
+ // modules into a single IIFE — so module-scope helpers use an `sb`/`SB_` prefix
17
+ // to stay unique across that shared scope.
18
+
19
+ import { validateSlideUses } from "../lib/image-intent.mjs";
8
20
 
9
21
  export const STORYBOARD_CANVAS_LANG = "storyboard-canvas";
10
- export const STORYBOARD_WIDTH = 1600;
11
- export const STORYBOARD_HEIGHT = 900;
12
-
13
- export const BODY_SAFE_AREA = {
14
- x: 80,
15
- y: 110,
16
- w: 1440,
17
- h: 670,
18
- };
19
-
20
- export const MIN_BLOCK_WIDTH = 180;
21
- export const MIN_BLOCK_HEIGHT = 96;
22
- export const MAX_BLOCKS = 8;
23
- const SB_MAX_ID_LENGTH = 64;
24
- export const MAX_DESIGN_ELEMENT_LENGTH = 56;
25
- export const MAX_CONTENT_LENGTH = 28;
26
22
  const SB_MAX_KEY_MESSAGE_LENGTH = 180;
27
- const SB_MAX_VISUAL_FAMILY_LENGTH = 64;
28
23
  const SB_MAX_LAYOUT_NAME_LENGTH = 64;
29
- const SB_MAX_DEPTH_NOTES = 6;
30
- const SB_MAX_DEPTH_NOTE_LENGTH = 220;
31
24
  export const MAX_IMAGE_PROMPT_LENGTH = 240;
32
25
  const SB_MAX_IMAGE_PROMPTS = 4;
33
26
  const SB_IMAGE_PROMPT_KEY = /^image\d{0,2}$/;
@@ -45,64 +38,6 @@ function sbAsFiniteNumber(value, fallback) {
45
38
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
46
39
  }
47
40
 
48
- function sbClamp(value, min, max) {
49
- return Math.min(Math.max(value, min), max);
50
- }
51
-
52
- function sbNormalizeBlock(value, index, warnings) {
53
- if (!sbIsRecord(value)) {
54
- warnings.push(`Block ${index + 1} is not an object and was ignored.`);
55
- return null;
56
- }
57
-
58
- const id = sbAsTrimmedString(value.id, SB_MAX_ID_LENGTH) || `block-${index + 1}`;
59
- const designElement = sbAsTrimmedString(
60
- value.designElement,
61
- MAX_DESIGN_ELEMENT_LENGTH,
62
- );
63
- const content = sbAsTrimmedString(value.content, MAX_CONTENT_LENGTH);
64
- if (!designElement || !content) {
65
- warnings.push(
66
- `Block "${id}" needs both designElement and content and was ignored.`,
67
- );
68
- return null;
69
- }
70
-
71
- const minW = MIN_BLOCK_WIDTH;
72
- const minH = MIN_BLOCK_HEIGHT;
73
- const maxW = BODY_SAFE_AREA.w;
74
- const maxH = BODY_SAFE_AREA.h;
75
- const w = sbClamp(sbAsFiniteNumber(value.w, 360), minW, maxW);
76
- const h = sbClamp(sbAsFiniteNumber(value.h, 150), minH, maxH);
77
- const x = sbClamp(
78
- sbAsFiniteNumber(value.x, BODY_SAFE_AREA.x),
79
- BODY_SAFE_AREA.x,
80
- BODY_SAFE_AREA.x + BODY_SAFE_AREA.w - w,
81
- );
82
- const y = sbClamp(
83
- sbAsFiniteNumber(value.y, BODY_SAFE_AREA.y),
84
- BODY_SAFE_AREA.y,
85
- BODY_SAFE_AREA.y + BODY_SAFE_AREA.h - h,
86
- );
87
- const emphasis =
88
- value.emphasis === "primary" ||
89
- value.emphasis === "secondary" ||
90
- value.emphasis === "tertiary"
91
- ? value.emphasis
92
- : undefined;
93
-
94
- return {
95
- id,
96
- x,
97
- y,
98
- w,
99
- h,
100
- ...(emphasis ? { emphasis } : {}),
101
- designElement,
102
- content,
103
- };
104
- }
105
-
106
41
  export function parseStoryboardCanvas(raw) {
107
42
  let parsed;
108
43
  try {
@@ -129,33 +64,8 @@ export function parseStoryboardCanvas(raw) {
129
64
  }
130
65
 
131
66
  const slide = Math.max(1, Math.round(sbAsFiniteNumber(parsed.slide, 1)));
132
- const rawBlocks = Array.isArray(parsed.blocks) ? parsed.blocks : [];
133
- if (!Array.isArray(parsed.blocks)) {
134
- warnings.push("Storyboard blocks must be an array.");
135
- }
136
-
137
- const blocks = rawBlocks
138
- .slice(0, MAX_BLOCKS)
139
- .map((block, index) => sbNormalizeBlock(block, index, warnings))
140
- .filter((block) => block !== null);
141
-
142
- if (rawBlocks.length > MAX_BLOCKS) {
143
- warnings.push(`Only the first ${MAX_BLOCKS} storyboard blocks are shown.`);
144
- }
145
-
146
- // An empty block list is a valid storyboard: a cover or closing slide whose
147
- // layout carries the text itself has no content blocks. Rejecting it dropped
148
- // the slide from concept generation — and shifted the server's slideCount so
149
- // the LAST slide fell off the request list too.
150
- if (rawBlocks.length > 0 && blocks.length === 0) {
151
- warnings.push("No storyboard block was valid; the canvas is empty.");
152
- }
153
-
154
67
  const keyMessage = sbAsTrimmedString(parsed.keyMessage, SB_MAX_KEY_MESSAGE_LENGTH);
155
- const visualFamily = sbAsTrimmedString(
156
- parsed.visualFamily,
157
- SB_MAX_VISUAL_FAMILY_LENGTH,
158
- );
68
+ const continues = Math.round(sbAsFiniteNumber(parsed.continues, 0));
159
69
  const layout = sbAsTrimmedString(parsed.layout, SB_MAX_LAYOUT_NAME_LENGTH);
160
70
  const selectionFields = {};
161
71
  if (parsed.role === "body" || parsed.role === "frame") selectionFields.role = parsed.role;
@@ -179,24 +89,54 @@ export function parseStoryboardCanvas(raw) {
179
89
  const rawContentNotes = Array.isArray(parsed.contentNotes)
180
90
  ? parsed.contentNotes
181
91
  : parsed.depthNotes;
92
+ // The slide's content is never shortened here: every concept prompt and
93
+ // worker brief reads it from this parse, and a cut note reached the image
94
+ // model mid-word (x80–x87: 195 of 606 notes were over the old 220 cap).
182
95
  const contentNotes = Array.isArray(rawContentNotes)
183
96
  ? rawContentNotes
184
- .slice(0, SB_MAX_DEPTH_NOTES)
185
- .map((note) => sbAsTrimmedString(note, SB_MAX_DEPTH_NOTE_LENGTH))
97
+ .map((note) => (typeof note === "string" ? note.trim() : ""))
186
98
  .filter(Boolean)
187
99
  : undefined;
100
+ // The slide's full content draft, written from the approved key points after
101
+ // sign-off: concept generation and authoring build from it.
102
+ const content = Array.isArray(parsed.content)
103
+ ? parsed.content
104
+ .map((line) => (typeof line === "string" ? line.trim() : ""))
105
+ .filter(Boolean)
106
+ : undefined;
107
+
108
+ // The one field normalization may NOT touch. Everything else in this parser
109
+ // is defensive — plan.md is human-edited, so an unreadable field is dropped
110
+ // and the record still opens. An image use is an APPROVED OBLIGATION: a
111
+ // malformed one has to surface as an error, because dropping it is exactly
112
+ // how a picture the user asked for disappears between the plan and the .pptx
113
+ // (implementation-guide §1). The contract's own strict parser decides;
114
+ // this module adds no image rule of its own.
115
+ const uses = validateSlideUses(parsed.assetUses, { slide });
116
+ if (!uses.value) {
117
+ return {
118
+ data: null,
119
+ warnings,
120
+ error: uses.issues[0]?.message ?? "assetUses could not be read.",
121
+ issues: uses.issues,
122
+ };
123
+ }
188
124
 
189
125
  return {
190
126
  data: {
191
127
  version: 1,
192
128
  slide,
193
- blocks,
194
- ...(visualFamily ? { visualFamily } : {}),
129
+ ...(continues >= 1 && continues < slide ? { continues } : {}),
195
130
  ...(layout ? { layout } : {}),
196
131
  ...selectionFields,
197
132
  ...(keyMessage ? { keyMessage } : {}),
198
133
  ...(contentNotes?.length ? { contentNotes } : {}),
134
+ ...(content?.length ? { content } : {}),
199
135
  ...(Object.keys(imagePrompts).length > 0 ? { imagePrompts } : {}),
136
+ // Empty means "no uses", which is the absence of the key — a slide that
137
+ // must CLEAR an obligation does it through the targeted-edit override
138
+ // file, not through an empty array in the plan.
139
+ ...(uses.value.length ? { assetUses: uses.value } : {}),
200
140
  },
201
141
  warnings,
202
142
  };
@@ -0,0 +1,212 @@
1
+ // The reader marks texts that read as ONE unit with a shared `block` id — a
2
+ // title and the description under it, or the pieces of one sentence painted
3
+ // in two styles ("under **21** days"). Such a block becomes one text element
4
+ // whose members are its `paragraphs`: the transcriber writes one box whose
5
+ // paragraphs stack in flow on the design's own line-height (the image only
6
+ // places the block), settle fits and moves it as one unit, and the export
7
+ // ships one text frame. Every paragraph keeps its role, weight and paint.
8
+ // Pieces set side by side on one line are ONE paragraph: the piece with the
9
+ // most words gives it its style and every piece painted otherwise is a styled
10
+ // `run` of it (an accent figure, a bold term) — x109 GTM 4 shipped a scorecard
11
+ // sentence as five boxes with gaps between them, and x107–x109 left 8–11 such
12
+ // blocks per run as separate texts. A sentence the reader broke at a run and
13
+ // at its wraps is joined again across those lines: a line of pieces and the
14
+ // line before or after it in the same style are one paragraph.
15
+ // A list item is a paragraph with `marker` (concept-lists.mjs): a title over
16
+ // its items is one block, written as one text box whose items are one native
17
+ // list. A block stays as its separate texts only when it cannot be written as
18
+ // one — a member a line glues to, rotated or self-painted text, members in
19
+ // different containers, pieces set apart by a gutter (a name and its value in
20
+ // two columns) — and the fold says so in a note: one sentence is never
21
+ // silently left in several boxes.
22
+ import { recordBlockDividers } from "./block-dividers.mjs";
23
+
24
+ export const PARAGRAPH_KEYS = ["kind", "x", "text", "role", "weight", "color", "colorHex", "colorToken", "ground", "groundHex", "align", "leadIn", "emphasis", "runs", "marker", "markerToken", "markerHex"];
25
+
26
+ // Pieces of one line further apart than this many line heights are two
27
+ // columns (a depot name and its share, 118 px apart), not one sentence (the
28
+ // gaps the reader measures between the pieces of a sentence: 2–16 px).
29
+ const RUN_GAP_LINES = 1.5;
30
+
31
+ const lineHeight = (m) => m.h / Math.max(1, String(m.text).split("\n").length);
32
+ const style = (m) => JSON.stringify([m.role ?? (m.kind === "number" ? "number" : null), m.weight ?? null, m.colorToken ?? null, m.colorHex ?? null, m.color ?? null]);
33
+ const paragraphOf = (m) => Object.fromEntries([["id", m.id], ...PARAGRAPH_KEYS.filter((k) => m[k] != null).map((k) => [k, m[k]])]);
34
+ const flatText = (t) => String(t).replace(/\s*\n\s*/g, " ").trim();
35
+ // No space after a lone opening mark, none before closing punctuation.
36
+ const joinText = (a, b) => (!a ? b : !b ? a : /^[(\[“‘"'«]$/.test(a.trim()) || /^[.,;:!?%)\]”’»]/.test(b) ? `${a}${b}` : `${a} ${b}`);
37
+ // The next painted line of the same sentence: an indented line continues the
38
+ // paragraph (concept-text.mjs textLines), and the break keeps it from being
39
+ // written as one unwrappable line.
40
+ const joinLines = (a, b) => (/^[.,;:!?%)\]”’»]/.test(b) ? `${a}${b}` : `${a}\n ${b}`);
41
+
42
+ /**
43
+ * The block's members in reading order: rows top to bottom (a member whose
44
+ * middle lies within a row's band sits on that row), each row left to right.
45
+ * The reader declared the block; this only orders what it named.
46
+ */
47
+ export function readingRows(members) {
48
+ const rows = [];
49
+ for (const m of [...members].sort((a, b) => a.y - b.y || a.x - b.x)) {
50
+ const row = rows.at(-1);
51
+ const mid = m.y + m.h / 2;
52
+ if (row && mid >= Math.min(...row.map((r) => r.y)) && mid <= Math.max(...row.map((r) => r.y + r.h))) row.push(m);
53
+ else rows.push([m]);
54
+ }
55
+ return rows.map((row) => row.sort((a, b) => a.x - b.x));
56
+ }
57
+
58
+ /** Why these rows cannot be one text element, or null. */
59
+ function rowsProblem(rows) {
60
+ for (const row of rows) {
61
+ for (let i = 1; i < row.length; i++) {
62
+ const a = row[i - 1];
63
+ const b = row[i];
64
+ const gap = b.x - (a.x + a.w);
65
+ if (gap > RUN_GAP_LINES * Math.max(lineHeight(a), lineHeight(b))) return "its pieces on one line are set apart by a gutter, two columns rather than one sentence";
66
+ if (gap < -0.5 * Math.min(lineHeight(a), lineHeight(b))) return "two of its pieces are painted over each other";
67
+ }
68
+ if (row.length > 1 && row.some((m) => m.marker || m.paragraphs)) return "a list item shares its line with other pieces";
69
+ }
70
+ const extent = (row) => [Math.min(...row.map((m) => m.x)), Math.max(...row.map((m) => m.x + m.w))];
71
+ for (let i = 1; i < rows.length; i++) {
72
+ const [a0, a1] = extent(rows[i - 1]);
73
+ const [b0, b1] = extent(rows[i]);
74
+ if (!(b0 < a1 && a0 < b1)) return "its lines do not stack in one column";
75
+ }
76
+ return null;
77
+ }
78
+
79
+ const bounds = (ms) => {
80
+ const x = Math.min(...ms.map((m) => m.x));
81
+ const y = Math.min(...ms.map((m) => m.y));
82
+ return { x, y, w: Math.max(...ms.map((m) => m.x + m.w)) - x, h: Math.max(...ms.map((m) => m.y + m.h)) - y };
83
+ };
84
+
85
+ /** One unit per row: its paragraphs, its box, and the style a neighbouring line must share to continue it. */
86
+ function rowUnit(row) {
87
+ if (row.length === 1) {
88
+ const [m] = row;
89
+ return { ...bounds(row), paras: m.paragraphs ?? [paragraphOf(m)], style: m.paragraphs ? null : style(m), pieces: false };
90
+ }
91
+ // The piece with the most words styles the paragraph; every other style is a run.
92
+ const base = row.reduce((best, m) => (flatText(m.text).length > flatText(best.text).length ? m : best));
93
+ const runs = row.filter((m) => style(m) !== style(base)).map((m) => Object.fromEntries(
94
+ Object.entries({ text: flatText(m.text), kind: m.kind, role: m.role, weight: m.weight, color: m.color, colorHex: m.colorHex, colorToken: m.colorToken }).filter(([, v]) => v != null),
95
+ ));
96
+ const para = { ...paragraphOf(base), x: row[0].x, text: row.reduce((t, m) => joinText(t, String(m.text).trim()), "") };
97
+ if (base !== row[0]) delete para.leadIn;
98
+ if (runs.length) para.runs = runs;
99
+ return { ...bounds(row), paras: [para], style: style(base), pieces: true };
100
+ }
101
+
102
+ const WORDS = new Set(["text", "number"]);
103
+
104
+ /**
105
+ * The indent a paragraph of `block` is written with, off the block's left
106
+ * edge: only structure, never alignment. A centred or right-aligned line has
107
+ * no left edge of its own — its painted x is where its width put it — so an
108
+ * indent would only shift the axis it is centred on (x110 GTM 3: "412" set
109
+ * `algn=ctr` with a 33 pt `marL`, off its tile's centre, which users had to
110
+ * undo by hand; 33–57 such paragraphs per run from x107 on). A list keeps its
111
+ * words' offset: the marker hangs in it. A plain left line keeps its offset
112
+ * only where a drawing of the same container ends in the strip it clears — the
113
+ * disc or icon a title stands beside; any other offset is the painting's noise
114
+ * and the line starts at the block's edge.
115
+ */
116
+ export function paragraphIndent(para, block, elements) {
117
+ const offset = Math.round((para.x ?? block.x) - block.x);
118
+ if (offset <= 1 || (para.align ?? "left") !== "left") return 0;
119
+ if (para.marker) return offset;
120
+ const clears = elements.some((d) => d.parent === block.parent && d.id !== block.id && !WORDS.has(d.kind)
121
+ && d.x + d.w > block.x - 1 && d.x + d.w <= block.x + offset + 2
122
+ && d.y < block.y + block.h && d.y + d.h > block.y);
123
+ return clears ? offset : 0;
124
+ }
125
+
126
+ /** A line of pieces and the line before or after it in the same style are one paragraph. */
127
+ function joinSentences(units) {
128
+ const out = [];
129
+ for (const u of units) {
130
+ const prev = out.at(-1);
131
+ const a = prev?.paras.length === 1 ? prev.paras[0] : null;
132
+ const b = u.paras.length === 1 ? u.paras[0] : null;
133
+ if (a && b && prev.style && prev.style === u.style && (prev.pieces || u.pieces) && !a.marker && !b.marker) {
134
+ a.text = joinLines(String(a.text).trim(), String(b.text).trim());
135
+ const runs = [...(a.runs ?? []), ...(b.runs ?? [])];
136
+ if (runs.length) a.runs = runs;
137
+ const emphasis = [...(a.emphasis ?? []), ...(b.emphasis ?? [])];
138
+ if (emphasis.length) a.emphasis = emphasis;
139
+ Object.assign(prev, bounds([prev, u]), { pieces: true });
140
+ continue;
141
+ }
142
+ out.push(u);
143
+ }
144
+ return out;
145
+ }
146
+
147
+ export function foldTextBlocks(elements, { connectors = [], notes = null } = {}) {
148
+ const referenced = new Set(connectors.flatMap((c) => [c.from, c.to]));
149
+ for (const e of elements) {
150
+ for (const end of [e.strokeEnds?.start, e.strokeEnds?.end]) if (end?.element) referenced.add(end.element);
151
+ }
152
+ const parents = new Set(elements.map((e) => e.parent).filter(Boolean));
153
+ const writable = (e) => (e.kind === "text" || e.kind === "number") && e.text != null
154
+ && !e.textRotation && !(e.fill && e.fill !== "none") && !referenced.has(e.id) && !parents.has(e.id);
155
+ const blocks = new Map();
156
+ for (const e of elements) {
157
+ if (!e.block) continue;
158
+ if (!blocks.has(e.block)) blocks.set(e.block, []);
159
+ blocks.get(e.block).push(e);
160
+ }
161
+ const ids = new Set(elements.map((e) => e.id));
162
+ const replace = new Map();
163
+ for (const [block, members] of blocks) {
164
+ if (members.length < 2) continue;
165
+ const rows = readingRows(members);
166
+ const problem = !members.every(writable) ? "a member is a line's end, a container, rotated or painted"
167
+ : !members.every((c) => c.parent === members[0].parent) ? "its members sit in different containers"
168
+ : rowsProblem(rows);
169
+ if (problem) {
170
+ notes?.push(`text block ${block} kept as ${members.length} texts — ${problem}`);
171
+ continue;
172
+ }
173
+ let id = block;
174
+ while (ids.has(id)) id = `${id}-text`;
175
+ ids.add(id);
176
+ const units = joinSentences(rows.map(rowUnit));
177
+ const paragraphs = units.flatMap((u) => u.paras);
178
+ const box = bounds(members);
179
+ const aligns = new Set(members.map((c) => c.align ?? "left"));
180
+ const first = rows[0][0];
181
+ // The box's own style: its first member's, or the paragraph its first line of pieces makes.
182
+ const lead = rows[0].length === 1 ? first : paragraphs[0];
183
+ const folded = {
184
+ id,
185
+ kind: "text",
186
+ parent: first.parent ?? null,
187
+ ...box,
188
+ text: paragraphs.map((p) => String(p.text)).join("\n"),
189
+ textSource: "visible",
190
+ role: lead.role ?? null,
191
+ weight: lead.weight ?? null,
192
+ color: lead.color ?? null,
193
+ colorHex: lead.colorHex ?? null,
194
+ colorToken: lead.colorToken ?? null,
195
+ align: aligns.size === 1 ? [...aligns][0] : "left",
196
+ // One seat for the block where its members agree (seat.mjs).
197
+ ...(first.seat && members.every((c) => JSON.stringify(c.seat ?? null) === JSON.stringify(first.seat)) ? { seat: first.seat } : {}),
198
+ // A member that is already a block of its own (a list read as one
199
+ // element) brings its paragraphs along.
200
+ paragraphs,
201
+ };
202
+ if ("ground" in first) folded.ground = first.ground;
203
+ if ("groundHex" in first) folded.groundHex = first.groundHex;
204
+ // A rule drawn between two of the lines rides with that paragraph boundary (block-dividers.mjs).
205
+ const dividers = recordBlockDividers(units, connectors, (u) => u.paras.length);
206
+ if (dividers.length) folded.dividers = dividers;
207
+ replace.set(first.id, folded);
208
+ for (const c of members) if (c !== first) replace.set(c.id, null);
209
+ }
210
+ if (!replace.size) return elements;
211
+ return elements.flatMap((e) => (replace.has(e.id) ? (replace.get(e.id) ? [replace.get(e.id)] : []) : [e]));
212
+ }