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,140 @@
1
+ // Text the reader reported that is the model talking about the ABSENCE of
2
+ // text, not a slide's words. A leaf module: the line intake needs it (a
3
+ // placeholder disc is wordless) and concept-spec imports the line intake.
4
+ // The model writes the literal ">null" for an absent text now and then, and a
5
+ // lone syntax mark ("." on a divider) the same way.
6
+ // The model's stand-ins for "no text" under a strict schema, observed across
7
+ // runs: ">null", "><null>", "/null", "/body", ">", "There is no text.".
8
+ const NO_TEXT = /^\W*(?:null|no text|there is no text|no visible text)\W*$/i;
9
+ // …and a string made only of SYNTAX: brackets, slashes, separators, quotes.
10
+ // That is what leaks when the model has no words to report, and no slide
11
+ // prints it.
12
+ //
13
+ // This replaced "no letter or digit", which was the same idea stated far too
14
+ // widely and deleted real copy (E26): an em-dash column meaning "not
15
+ // applicable", a ✓/✗ comparison matrix, a bullet glyph used as a value, a "?"
16
+ // cell for an unknown. Those are typography, not syntax — a slide's own words
17
+ // even when they carry no letter. ">" and "." still go, because they are.
18
+ const STRUCTURAL_ONLY = /^[<>/\\|:=.,;'"`()[\]{}\s]+$/;
19
+ // …and any lone token wrapped in slashes or angle brackets ("/slide", "<img>")
20
+ // — the model's placeholder grammar, never a slide's words.
21
+ const TOKEN_ONLY = /^[<>\/\\]+[A-Za-z_-]{1,24}[<>\/\\]*$/;
22
+ // …and any spaceless token that carries BOTH an angle bracket and a slash:
23
+ // markup the model leaked instead of words ("></xsl:value-of>", x38 ×26). By
24
+ // shape, not by list — the exact scrap changes between runs. A threshold like
25
+ // ">45%" has no slash and survives; "50/50" has no bracket.
26
+ const MARKUP_ONLY = /^(?=\S{1,40}$)(?=[^\s]*[<>])(?=[^\s]*[/\\])\S+$/;
27
+ // …and any short SPACELESS token that carries "null" as a segment ("s:null",
28
+ // "x=null"). The `\S{1,24}` lookahead is load-bearing: `.{1,24}` let a space
29
+ // close the segment, so "null pointer" and "null hypothesis" — two real
30
+ // phrases — were deleted as symbols (E26).
31
+ const NULL_SEGMENT = /^(?=\S{1,24}$)\S*(?:^|[^a-z])null(?:[^a-z]|$)\S*$/i;
32
+
33
+ /**
34
+ * The text a slide should print, or null when the string is the model talking
35
+ * about the absence of text rather than reporting it.
36
+ *
37
+ * `printed` is the reader's own classification (`textSource === "visible"`:
38
+ * "those characters are printed in the image"). It suppresses the two rules
39
+ * that cannot be decided by shape alone — the word "null" and its symbol
40
+ * forms, which are a stand-in in one deck and a table cell's value in the
41
+ * next. It does NOT suppress the markup rules: leaked syntax is never a
42
+ * slide's words, whatever the reader claims about it (x38 shipped
43
+ * "></xsl:value-of>" 26 times under exactly that claim).
44
+ *
45
+ * Deleting copy still needs an explicit classification the other way — that is
46
+ * `dropStandIns`, which has the element and honours `textSource:
47
+ * "placeholder"` above everything here.
48
+ */
49
+ export function cleanText(t, { printed = false } = {}) {
50
+ if (t == null) return null;
51
+ const s = String(t).trim();
52
+ if (s === "" || STRUCTURAL_ONLY.test(s) || TOKEN_ONLY.test(s) || MARKUP_ONLY.test(s)) return null;
53
+ if (!printed && (NO_TEXT.test(s) || NULL_SEGMENT.test(s))) return null;
54
+ return typeof t === "string" ? typeset(t) : t;
55
+ }
56
+
57
+ // The concept image model writes typography the approved plan never had — a
58
+ // clause em-dash, curly quotes — and our own content lint then sent the agent
59
+ // to hand-patch what the transcriber wrote (T21: em-dash 101 of 113 slide
60
+ // occurrences first appeared at transcribe). So slide copy is typeset once,
61
+ // here, for every consumer of the spec. A dash that JOINS words becomes the
62
+ // punctuation the lint names: ": " when spaced, ", " when closed, commas for a
63
+ // parenthetical pair, an en-dash between two numbers, spaced or closed
64
+ // ("2020 — 2024", "Q1—Q3", "10 — 20%") or from a number to now ("2020—present":
65
+ // a range); a dash opening the text goes. A dash that IS the value — an N/A cell "—", "—%", a legend
66
+ // "— = not reported" — stays (E26). Curly quotes straighten, except a German
67
+ // „…“ pair, which is correct typography. `quote` is what a double quote
68
+ // becomes: chrome props are JSX attribute strings, where a straight `"`
69
+ // cannot be escaped, so they pass "'".
70
+ // A word end may carry closers, the German “ ‘ among them (`„Zitat“ — sagte er`).
71
+ const CLAUSE_DASH = /(?<=[\p{L}\p{N}][)\]"'’”“‘%]*)(\s*)—(\s*)(?=[(["'‘“„]*[\p{L}\p{N}])/gu;
72
+ const LEADING_DASH = /^(\s*)—\s*(?=[(["'‘“„]*[\p{L}\p{N}])/u;
73
+ // An open end ("present") counts only where it ends the phrase: "2019 — today
74
+ // 40 teams" is a clause.
75
+ const RANGE_DASH = /(?<=\d[\p{L}%]*)\s*—\s*(?=\p{L}*\d|(?:present|today|now|heute)(?![ \t]*[\p{L}\p{N}]))/giu;
76
+ export function typeset(text, { quote = '"' } = {}) {
77
+ const doubles = quote === '"' ? /(„[^“”„]*[“”])|[“”]/g : /(„[^“”„"]*[“”])|[“”"]/g;
78
+ return String(text)
79
+ .replace(RANGE_DASH, "–")
80
+ .replace(/[^.!?]*[.!?]*/g, (sentence) => {
81
+ const pair = (sentence.match(CLAUSE_DASH) ?? []).length > 1;
82
+ return sentence.replace(CLAUSE_DASH, (_, a, b) => `${pair || !/[^\S\n]/.test(a + b) ? "," : ":"}${/\n/.test(a + b) ? "\n" : " "}`);
83
+ })
84
+ .replace(LEADING_DASH, "$1")
85
+ .replace(doubles, (m, german) => german ?? quote)
86
+ .replace(/[‘’]/g, "'");
87
+ }
88
+
89
+ // The spec's `\n` is where the image breaks the line. A line that starts with
90
+ // a bullet, a relation sign, a number or a capital is a new line (a list item,
91
+ // a new sentence, a threshold, a stacked label); one that starts lower-case or
92
+ // indented continues the previous line (a paragraph wrap the box width
93
+ // reproduces on its own). A wrap after a hyphen splits a compound
94
+ // ("revenue-\noperations"): it rejoins without a space.
95
+ // x27: joining everything collapsed every bullet list into one run of text.
96
+ // x37 GTM 4: a threshold column (">45%", ">60%") came out as one run — a
97
+ // relation sign opens a line as surely as a bullet does.
98
+ const NEW_LINE = /^(?:[•·‣*\-–▪◦✓✔→<>≥≤±~]|\d+[.)]\s|[A-ZÄÖÜ0-9])/;
99
+ export function textLines(s) {
100
+ const out = [];
101
+ for (const raw of String(s).split("\n")) {
102
+ const line = raw.trim();
103
+ if (!line) continue;
104
+ if (out.length && !BULLET.test(line) && !(NEW_LINE.test(line) && !/^\s/.test(raw))) out[out.length - 1] += `${/\p{L}-$/u.test(out[out.length - 1]) ? "" : " "}${line}`;
105
+ else out.push(line);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // A list marker typed into the text: a bullet-like glyph or a tick, then the
111
+ // item's words. The glyph is the image's drawing of the marker; the list is
112
+ // structure (concept-lists.mjs): the item ships as a native PowerPoint bullet
113
+ // in the design's own glyph, hanging its wrapped lines under the text. A
114
+ // numbered line ("1. Plan") is a heading or a step unless the reader declared
115
+ // it an item, so no number is read here.
116
+ const BULLET = /^([•·▪◦‣●■□◆\-–*✓✔✗✘])[ \t]+(?=\S)/;
117
+ // A declared number item may still carry its ordinal in the text ("1. Plan").
118
+ const ORDINAL = /^(?:\d{1,2}|[a-z])[.)][ \t]+(?=\S)/i;
119
+ /** The marker kind a line opens with (`bullet` | `check` | `cross`), or null. */
120
+ export function markerOfLine(line) {
121
+ const glyph = BULLET.exec(String(line ?? ""))?.[1];
122
+ return glyph ? (/[✓✔]/.test(glyph) ? "check" : /[✗✘]/.test(glyph) ? "cross" : "bullet") : null;
123
+ }
124
+ /** The line without the marker the reader typed into it (`kind` decides what counts as one). */
125
+ export function stripMarker(line, kind) {
126
+ const s = String(line ?? "");
127
+ return kind === "number" ? s.replace(ORDINAL, "") : s.replace(BULLET, "");
128
+ }
129
+
130
+ // A text whose every line opens with the SAME marker is a bullet list.
131
+ export function bulletItems(text) {
132
+ const lines = textLines(text ?? "");
133
+ if (lines.length < 2) return null;
134
+ const markers = lines.map((l) => BULLET.exec(l)?.[1] ?? null);
135
+ if (markers.some((m) => m == null) || new Set(markers).size !== 1) return null;
136
+ return { marker: markers[0], items: lines.map((l) => l.replace(BULLET, "")) };
137
+ }
138
+
139
+ /** Did the reader say these characters are printed on the image? */
140
+ export const textIsPrinted = (e) => e?.textSource === "visible";
@@ -0,0 +1,57 @@
1
+ import { splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
2
+
3
+ // The slide numbers the plan's storyboards actually carry, ascending. This is
4
+ // the set concept generation must cover — the server's slideCount is only the
5
+ // number of beats IT parsed, and one beat it drops (an older schema rejecting
6
+ // a cover's empty block list) would otherwise also drop the LAST slide from a
7
+ // 2..slideCount request range.
8
+ export function plannedSlideNumbers(planMarkdown) {
9
+ const slides = new Set();
10
+ for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
11
+ if (segment.type !== "storyboard" || !segment.data) continue;
12
+ if (Number.isFinite(segment.data.slide)) slides.add(segment.data.slide);
13
+ }
14
+ return [...slides].sort((a, b) => a - b);
15
+ }
16
+
17
+ /**
18
+ * Split a per-slide concept batch into dependency waves: every slide that
19
+ * continues no earlier slide first, then each slide one wave after the slide
20
+ * its storyboard `continues`. The add-in caches every continued concept, so a
21
+ * later wave's request finds the predecessor an earlier wave made.
22
+ */
23
+ export function conceptGenerationWaves(planMarkdown) {
24
+ const planned = plannedSlideNumbers(planMarkdown);
25
+ const previous = new Map();
26
+ for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
27
+ if (segment.type !== "storyboard" || !segment.data) continue;
28
+ const { slide, continues } = segment.data;
29
+ // The parser keeps only backward links, so the chains below always end.
30
+ if (planned.includes(continues)) previous.set(slide, continues);
31
+ }
32
+ const depth = (slide) => (previous.has(slide) ? depth(previous.get(slide)) + 1 : 0);
33
+ const waves = [];
34
+ for (const slide of planned) (waves[depth(slide)] ??= []).push(slide);
35
+ return waves;
36
+ }
37
+
38
+ /**
39
+ * A slide and every earlier slide its storyboard's `continues` chain reaches,
40
+ * in that order — the slides whose image uses its concept depends on. The
41
+ * add-in walks the same links (`continuationChain` in concept-sequence.ts).
42
+ */
43
+ export function continuationChain(planMarkdown, slide) {
44
+ const continues = new Map();
45
+ for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
46
+ if (segment.type !== "storyboard" || !segment.data) continue;
47
+ continues.set(segment.data.slide, segment.data.continues);
48
+ }
49
+ const chain = [];
50
+ for (let s = slide; s !== undefined && !chain.includes(s); ) {
51
+ chain.push(s);
52
+ const previous = continues.get(s);
53
+ // Only backward links: a forward or self link ends the chain.
54
+ s = Number.isFinite(previous) && previous < s ? previous : undefined;
55
+ }
56
+ return chain;
57
+ }
@@ -0,0 +1,139 @@
1
+ // Copy-fit: the type is the slide's one ladder on the design's ramp; where a
2
+ // text still runs past the room the image gives it after the settle fit (and
3
+ // after the ladder's one step down), its WORDS shorten, never its size. One
4
+ // batched model call per slide, made by the client (settle-driver.mjs
5
+ // `fitCopy`); the next settle render re-measures the result.
6
+ // Mirrored into the add-in by scripts/sync-transcription-runtime.mjs — no Node
7
+ // or CLI imports here.
8
+ import { hardLineCount, textRoom } from "./settle.mjs";
9
+
10
+ const HEAD = new Set(["title", "section", "number", "badge"]);
11
+ const BULLET = /^\s*[•●▪■◦○\-–—✓✔→►▸]\s/;
12
+ // A label or heading keeps its words: shortening "Q3 revenue" says less, not the same in less room.
13
+ const MIN_WORDS = 4;
14
+ // A budget under this share of the text means the box is wrong, not the copy.
15
+ const MIN_SHARE = 0.25;
16
+ // Budgets aim a little under the room: the rewrite is measured only by the next render.
17
+ const MARGIN = 0.95;
18
+
19
+ const flat = (t) => String(t ?? "").replace(/\s+/g, " ").trim();
20
+ const words = (t) => flat(t).split(" ").filter(Boolean).length;
21
+ const fixed = (t) => t.role === "number" || t.kind === "number" || String(t.text ?? "").split("\n").some((l) => BULLET.test(l));
22
+ // Characters a line of the box holds: a wrapped text fills every line but its
23
+ // last, which is half full on average.
24
+ const perLine = (chars, lines, fill = 1) => (chars / Math.max(0.5, lines - 0.5)) * Math.min(1.2, Math.max(1, fill));
25
+ const prefixOf = (t) => (t.leadIn ? String(t.leadIn).trim().replace(/[:\s]+$/, "") : null);
26
+
27
+ /**
28
+ * The texts on a settled slide that still run past their room, each with a
29
+ * character budget: `{ id, el, para, role, text, maxChars, wrap, keepPrefix?, why? }`.
30
+ * `why` marks one copy-fit leaves alone (`short`, `geometry`, `fixed`); the
31
+ * overrun still counts for the ladder's step down. `tighten` scales every
32
+ * budget (the second round asks for less).
33
+ */
34
+ export function textOverruns(spec, measured, { tighten = 1 } = {}) {
35
+ const slot = spec.slot;
36
+ const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
37
+ const out = [];
38
+ const push = (e, para, t, lines, cpl, wrap, lineCount, held = false) => {
39
+ const text = flat(t.text);
40
+ const maxChars = Math.floor(lines * cpl * MARGIN * tighten);
41
+ const why = held || fixed(t) ? "fixed" : words(text) < MIN_WORDS ? "short" : maxChars < MIN_SHARE * text.length ? "geometry" : null;
42
+ out.push({ id: para == null ? e.id : `${e.id}#${para}`, el: e.id, para, role: t.role ?? e.role ?? "body", text, maxChars, wrap, lines: lineCount, maxLines: lines,
43
+ ...(prefixOf(t) ? { keepPrefix: prefixOf(t) } : {}), ...(why ? { why } : {}) });
44
+ };
45
+ for (const e of spec.elements ?? []) {
46
+ if (!(e.kind === "text" || e.kind === "number") || e.text == null || e.textRotation || e.textFrame) continue;
47
+ const m = measured.get(e.id);
48
+ if (!(m?.inkWidth > 0) || !(m.lines > 0) || !(e.w > 0 && e.h > 0)) continue;
49
+ const r = textRoom(spec, e, { slot, byId });
50
+ const lineH = (m.inkHeight ?? m.h) / m.lines;
51
+ const top = m.inkY ?? m.y;
52
+ // The lines the room holds from where the text starts.
53
+ const room = Math.max(1, Math.floor((e.y + r.h - top + 1) / lineH));
54
+ const text = flat(e.text);
55
+ if (!e.paragraphs?.length) {
56
+ if (m.lines > room) push(e, null, e, room, perLine(text.length, m.lines, e.w / m.inkWidth), true, m.lines);
57
+ // A line the settle fit could neither widen nor wrap: shorter on one line.
58
+ else if (m.inkWidth > r.w + 1 && m.lines <= hardLineCount(e)) push(e, null, e, 1, (text.length * r.w) / m.inkWidth, false, m.lines);
59
+ continue;
60
+ }
61
+ if (m.lines <= room) continue;
62
+ // A block: headings keep their lines, the body paragraphs share the rest.
63
+ const paras = e.paragraphs;
64
+ const share = paras.map((p) => flat(p.text).length);
65
+ const per = m.paraLines?.length === paras.length ? m.paraLines
66
+ : share.map((n) => Math.max(1, Math.round((m.lines * n) / Math.max(1, share.reduce((a, b) => a + b, 0)))));
67
+ const body = paras.map((_, i) => i).filter((i) => !HEAD.has(paras[i].role) && !fixed(paras[i]));
68
+ const left = room - per.reduce((n, l, i) => n + (body.includes(i) ? 0 : l), 0);
69
+ const bodyLines = body.reduce((n, i) => n + per[i], 0);
70
+ for (const i of body) {
71
+ const lines = Math.max(1, Math.floor((left * per[i]) / Math.max(1, bodyLines)));
72
+ if (per[i] > lines) push(e, i, paras[i], lines, perLine(share[i], per[i]), true, per[i]);
73
+ }
74
+ if (!body.length) push(e, null, e, room, perLine(text.length, m.lines), true, m.lines, true);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** The plan's words for one slide (its storyboard beat), as the prompt quotes them. */
80
+ function planLines(beat) {
81
+ if (!beat) return "";
82
+ const notes = Array.isArray(beat.contentNotes) ? beat.contentNotes : [];
83
+ return [beat.keyMessage ? `Key message: ${beat.keyMessage}` : "", notes.length ? `Content notes:\n- ${notes.join("\n- ")}` : ""].filter(Boolean).join("\n");
84
+ }
85
+
86
+ /** Every visible text on the slide, one line: the rewrite keeps the slide's voice. */
87
+ export const slideContext = (spec) => (spec.elements ?? []).filter((e) => e.text != null && e.kind !== "icon").map((e) => flat(e.text)).filter(Boolean).join(" | ");
88
+
89
+ export const COPY_FIT_SCHEMA = {
90
+ type: "object", additionalProperties: false, required: ["items"],
91
+ properties: { items: { type: "array", items: { type: "object", additionalProperties: false, required: ["id", "text"], properties: { id: { type: "string" }, text: { type: "string" } } } } },
92
+ };
93
+
94
+ /** The one request for a slide: `items` are `{ id, role, text, maxChars, keepPrefix? }`. */
95
+ export function copyFitPrompt({ items, context = "", plan = null }) {
96
+ const plain = planLines(plan);
97
+ return [
98
+ "You fit slide copy into fixed text boxes. The type size is fixed by the brand; only the words may change.",
99
+ "For each item, rewrite `text` to at most `maxChars` characters (count spaces), as one piece of natural copy: full phrases or a sentence, never a list of fragments.",
100
+ "Write plain, natural slide copy a consultant would put on a slide. Keep the language of the original and its key message.",
101
+ "To save space, first drop filler, hedges, redundancy and secondary clauses, then secondary facts; keep the headline number of the text.",
102
+ "Never abbreviate words (no 'Vol.', 'incr.', 'exp'), never use telegraphic shorthand, slashes or semicolon lists, never invent facts, no ellipsis.",
103
+ "Keep list numbering and prefixes like '1.' or '4.'. Keep the capitalization style and end punctuation style of the original.",
104
+ "If `keepPrefix` is given, the text must still start with it verbatim.",
105
+ "",
106
+ ...(plain ? ["The APPROVED PLAN for this slide is below. Its facts, figures and claims are what must survive; words the mockup added beyond the plan go first.", plain, ""] : []),
107
+ `Slide context (all texts on the slide): ${context}`,
108
+ "",
109
+ `Items: ${JSON.stringify(items.map(({ id, role, text, maxChars, keepPrefix }) => ({ id, role, text, maxChars, ...(keepPrefix ? { keepPrefix } : {}) })))}`,
110
+ ].join("\n");
111
+ }
112
+
113
+ /**
114
+ * The spec with each answered text in place: `answers` is `[{ id, text }]`.
115
+ * A rewrite that is no shorter, or lost its lead-in, is dropped. Returns
116
+ * `{ spec, changes }`.
117
+ */
118
+ export function applyCopyFit(spec, items, answers) {
119
+ const s = structuredClone(spec);
120
+ const byId = new Map(s.elements.map((e) => [e.id, e]));
121
+ const said = new Map((answers ?? []).map((a) => [a?.id, flat(a?.text)]));
122
+ const changes = [];
123
+ for (const item of items) {
124
+ const text = said.get(item.id);
125
+ const e = byId.get(item.el);
126
+ if (!text || !e || text.length >= item.text.length) continue;
127
+ if (item.keepPrefix && !text.startsWith(item.keepPrefix)) continue;
128
+ const t = item.para == null ? e : e.paragraphs?.[item.para];
129
+ if (!t) continue;
130
+ t.text = text;
131
+ if (Array.isArray(t.emphasis)) t.emphasis = t.emphasis.filter((x) => text.includes(flat(x)));
132
+ if (Array.isArray(t.runs)) t.runs = t.runs.filter((r) => text.includes(flat(r.text)));
133
+ if (item.para != null) e.text = e.paragraphs.map((p) => p.text).join("\n");
134
+ // A paragraph that wrapped keeps wrapping, however short it got.
135
+ if (item.wrap) e.wrap = true;
136
+ changes.push(`${item.id}: copy ${item.text.length}→${text.length} characters to fit its room (${item.lines} lines, room for ${item.maxLines})`);
137
+ }
138
+ return { spec: s, changes };
139
+ }