slidemind 0.3.20 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,410 @@
1
+ // One read chart as a native `<Chart>`: its data, the design's chart colours by
2
+ // what each mark means, and the labels the image shows — on the chart itself,
3
+ // in the number format it sets them in. The data and the labels come from the
4
+ // reader's spec; the colours from the design bundle (design-tokens.mjs
5
+ // `chartPalette`), never from the painting.
6
+ import { chartPalette, contrastRatio } from "./design-tokens.mjs";
7
+ import { waterfallAsStacked } from "./concept-geometry.mjs";
8
+ import { holeSizeFor, ringRect } from "./chart-hole.mjs";
9
+ import { figureOf, labelKey, onlyFigure } from "./chart-labels.mjs";
10
+
11
+ const q = (s) => JSON.stringify(String(s));
12
+
13
+ // A figure in a label: "95.4%", "€1.4m", "1,200", "+3.7pp", "95,4 %".
14
+ const FIGURE = /^(.*?)([-+−]?)(\d[\d.,\s]*\d|\d)(.*)$/s;
15
+ const num = (t) => {
16
+ const m = /-?\d+(?:[.,]\d+)?/.exec(String(t).replace(/(\d),(\d{3})/g, "$1$2"));
17
+ return m ? Number(m[0].replace(",", ".")) : null;
18
+ };
19
+ const near = (a, b) => a != null && b != null && Math.abs(a - b) <= Math.max(0.05, Math.abs(b) * 0.01);
20
+
21
+ // A figure's digits, each way its separators can be read: "1,200" is 1200
22
+ // grouped or 1.2 with a decimal comma; "95.4" is 95.4.
23
+ function readings(digits) {
24
+ const plain = digits.replace(/\s/g, "");
25
+ const out = [{ read: Number(plain.replace(/[.,]/g, "")), decimals: 0, grouped: /[.,]/.test(plain) }];
26
+ for (const sep of [".", ","]) {
27
+ const at = plain.lastIndexOf(sep);
28
+ const decimals = plain.length - at - 1;
29
+ if (at < 0 || decimals < 1 || decimals > 3) continue;
30
+ out.push({
31
+ read: Number(`${plain.slice(0, at).replace(/[.,]/g, "")}.${plain.slice(at + 1)}`),
32
+ decimals,
33
+ grouped: /[.,]/.test(plain.slice(0, at)),
34
+ });
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // A label carries a value when its figure IS the value at the label's own
40
+ // precision: "95.4%" carries 95.4, the axis tick "96%" beside it does not.
41
+ const carries = (reading, value) => Math.abs(reading.read - Math.abs(value)) <= 0.5 * 10 ** -reading.decimals + 1e-9;
42
+
43
+ const literal = (s) => (s ? `"${s.replace(/"/g, "")}"` : "");
44
+
45
+ /**
46
+ * The number format the image's value labels are set in — decimals, thousands,
47
+ * a sign, a unit before or after the figure — read from the labels that carry
48
+ * the chart's own values: "95.4%" -> `0.0"%"`, "€1.4m" -> `"€"0.0"m"`,
49
+ * "+3.7pp" -> `+0.0"pp";-0.0"pp";0.0"pp"`. The decimal separator is the
50
+ * viewer's (PowerPoint localises it), so a German PowerPoint shows "95,4%".
51
+ * Null when no label carries a value, the labels disagree, or they say no more
52
+ * than General does.
53
+ */
54
+ export function labelNumberFormat(values, seen) {
55
+ const forms = [];
56
+ for (const value of values) {
57
+ for (const label of seen) {
58
+ const m = FIGURE.exec(String(label).trim());
59
+ const reading = m && readings(m[3]).find((r) => carries(r, value));
60
+ if (!reading) continue;
61
+ forms.push({ pre: m[1].trim(), sign: m[2] === "+", decimals: reading.decimals, grouped: reading.grouped, post: m[4].trim() });
62
+ break;
63
+ }
64
+ }
65
+ if (!forms.length) return null;
66
+ const [first] = forms;
67
+ if (forms.some((f) => f.pre !== first.pre || f.post !== first.post)) return null;
68
+ const decimals = Math.max(...forms.map((f) => f.decimals));
69
+ const grouped = forms.some((f) => f.grouped);
70
+ const signed = forms.some((f) => f.sign);
71
+ if (!first.pre && !first.post && !decimals && !grouped && !signed) return null;
72
+ const body = `${grouped ? "#,##0" : "0"}${decimals ? `.${"0".repeat(decimals)}` : ""}`;
73
+ const part = (sign) => `${sign}${literal(first.pre)}${body}${literal(first.post)}`;
74
+ return signed ? `${part("+")};${part("-")};${part("")}` : part("");
75
+ }
76
+
77
+ /**
78
+ * The ground a chart is drawn on: the nearest container holding it that the
79
+ * reader saw painted, else the slide's own (the design's light or dark ground).
80
+ */
81
+ function groundOf(e, spec, tokens) {
82
+ const byId = new Map((spec.elements ?? []).map((o) => [o.id, o]));
83
+ for (let p = byId.get(e.parent); p; p = byId.get(p.parent)) {
84
+ if (p.fill && p.fill !== "none" && p.fillHex) return p.fillHex;
85
+ }
86
+ const name = spec.background === "dark" ? "--theme-tx1" : "--theme-bg1";
87
+ return tokens.find((t) => t.name === name)?.hex ?? null;
88
+ }
89
+
90
+ // A filled mark the ground swallows is no colour for this chart.
91
+ const SEEN_ON_GROUND = 1.25;
92
+ const QUIET_ON_GROUND = 1.1;
93
+
94
+ /**
95
+ * The design's chart palette as this chart's ground shows it: a slot the ground
96
+ * swallows (a pale fill on a pale card, a dark one on an ink panel) leaves the
97
+ * cycle, and a role painted in it takes the first colour that stays. Null when
98
+ * the bundle declares no theme: the master then paints its own series.
99
+ */
100
+ function paletteOn(e, spec, tokens) {
101
+ const palette = chartPalette(tokens);
102
+ if (!palette) return null;
103
+ const ground = groundOf(e, spec, tokens);
104
+ const hexOf = (p) => (p?.rgb ? `#${p.rgb}` : tokens.find((t) => t.name === `--theme-${p?.scheme}`)?.hex ?? null);
105
+ const seen = (p) => { const hex = hexOf(p); return !ground || !hex || (contrastRatio(hex, ground) ?? 0) >= SEEN_ON_GROUND; };
106
+ const kept = palette.series.filter(seen);
107
+ const series = kept.length ? kept : palette.series;
108
+ const roles = Object.fromEntries(Object.entries(palette.roles).map(([r, p]) => [r, p && seen(p) ? p : series[0]]));
109
+ // A mark set back only needs to part from the ground, not carry a series.
110
+ const quietSeen = (p) => { const hex = hexOf(p); return !ground || !hex || (contrastRatio(hex, ground) ?? 0) >= QUIET_ON_GROUND; };
111
+ return { series, roles, quiet: (palette.quiet ?? []).filter(quietSeen) };
112
+ }
113
+
114
+ /**
115
+ * One paint per series (or point): the colour its role names where the reader
116
+ * judged one, else the next colour of the palette that no role on this chart
117
+ * has taken — so a highlighted bar never shares its colour with a plain one.
118
+ */
119
+ function paintsFor(roles, count, palette) {
120
+ const key = (p) => JSON.stringify(p);
121
+ const named = (r) => r && r !== "series" && palette.roles[r];
122
+ const taken = new Set(roles.filter(named).map((r) => key(palette.roles[r])));
123
+ // Several marks set back take the design's quiet neutrals one by one, never
124
+ // one grey for all (x113 QBR 3: three slices beside the highlighted lane all
125
+ // context grey); a colour another role here has taken stays that role's.
126
+ const contexts = roles.slice(0, count).filter((r) => r === "context").length;
127
+ const others = new Set(roles.filter((r) => named(r) && r !== "context").map((r) => key(palette.roles[r])));
128
+ const quiet = contexts > 1 ? (palette.quiet ?? []).filter((p) => p && !others.has(key(p))) : [];
129
+ for (const p of quiet.slice(0, contexts)) taken.add(key(p));
130
+ // The series palette first, then the role colours no mark here has taken:
131
+ // more slices than the palette holds still read apart before it repeats.
132
+ const cycle = [];
133
+ for (const p of [...palette.series, ...Object.values(palette.roles)]) {
134
+ if (p && !taken.has(key(p)) && !cycle.some((q) => key(q) === key(p))) cycle.push(p);
135
+ }
136
+ if (!cycle.length) cycle.push(...palette.series);
137
+ let k = 0;
138
+ let c = 0;
139
+ return Array.from({ length: count }, (_, i) => {
140
+ if (roles[i] === "context" && quiet.length) return c < quiet.length ? quiet[c++] : cycle[k++ % cycle.length];
141
+ return named(roles[i]) ? palette.roles[roles[i]] : cycle[k++ % cycle.length];
142
+ });
143
+ }
144
+
145
+ // How a mark is drawn, as the reader read it (`chart.draws`), never its colour:
146
+ // a plan or target bar as an empty dashed outline, a target as a dashed line.
147
+ const DASH = { dashed: "dash", dotted: "sysDot" };
148
+ const hollow = (draw) => draw === "outlined" || draw === "dashed" || draw === "dotted";
149
+
150
+ /** A filled mark's style (a bar, a slice, a point of either) in `paint`, drawn as read. */
151
+ function markStyle(paint, draw) {
152
+ if (!hollow(draw)) return paint ? { fill: paint } : {};
153
+ return { fill: "none", line: { ...(paint ? { color: paint } : {}), widthPt: 1.25, ...(DASH[draw] ? { dash: DASH[draw] } : {}) } };
154
+ }
155
+
156
+ /** A line series' style in `paint`, drawn as read: a dashed or dotted line marks no points. */
157
+ function lineStyle(paint, draw) {
158
+ const dash = DASH[draw];
159
+ return { line: { ...(paint ? { color: paint } : {}), ...(dash ? { dash } : {}) }, ...(dash ? { marker: { symbol: "none" } } : {}) };
160
+ }
161
+
162
+ /**
163
+ * The value axis as the image paints it: the axis the reader's tick labels
164
+ * spell (folded back by chart-labels.mjs: visible, its range, its step, its
165
+ * format), else the start the reader read where the marks visibly do not stand
166
+ * on 0 (`axisStart`, a cut axis — x113 QBR 2: ±1pp steps beside 19% totals
167
+ * drawn as slivers) — kept only below every mark, never cutting one off.
168
+ * Null: the axis stays as the chart's defaults draw it.
169
+ */
170
+ function valueAxisOf(c, lowest, notes, id) {
171
+ const axis = c.folded?.axis;
172
+ if (axis && Number.isFinite(axis.min) && Number.isFinite(axis.max) && axis.max > axis.min) {
173
+ return { visible: true, majorGridlines: false, min: axis.min, max: axis.max, ...(axis.step > 0 ? { majorUnit: axis.step } : {}), ...(axis.format ? { numberFormat: axis.format } : {}) };
174
+ }
175
+ const start = typeof c.axisStart === "number" && Number.isFinite(c.axisStart) ? c.axisStart : null;
176
+ if (start == null || start === 0) return null;
177
+ if (!(lowest > start)) {
178
+ notes.push(`chart ${id}: the reader read the value axis starting at ${start}, but a mark ends at ${lowest} — kept on 0, the axis never cuts a mark off`);
179
+ return null;
180
+ }
181
+ return { visible: false, majorGridlines: false, min: start };
182
+ }
183
+
184
+ const LEGEND_POS = { top: "t", bottom: "b", left: "l", right: "r" };
185
+
186
+ /**
187
+ * The chart's legend as the image draws it: where the reader saw one (or where
188
+ * the chart's own legend rows were drawn as separate texts and folded back,
189
+ * chart-labels.mjs), at the side it names. A spec that says nothing: a legend
190
+ * for more than one series, and for a pie whose slices the image names.
191
+ */
192
+ function legendOf(c, type, seriesCount, small, categories = []) {
193
+ const declared = typeof c.legend === "string" ? c.legend : null;
194
+ // A pie's legend names its slices: shown where the image names them (x111
195
+ // QBR 3: the doughnut's slice names were read, no legend was built, and the
196
+ // agent typed one beside it as four text boxes).
197
+ const sliced = ["pie", "doughnut"].includes(type);
198
+ const named = sliced && (c.labels ?? []).some((t) => categories.some((cat) => cat && String(t).trim().toLowerCase() === String(cat).trim().toLowerCase()));
199
+ const show = c.folded?.legend === true || (!small && (declared ? declared !== "none" : seriesCount > 1 || named));
200
+ return { show, pos: LEGEND_POS[declared] ?? LEGEND_POS[c.folded?.side] ?? (sliced ? "r" : "b") };
201
+ }
202
+
203
+ // The painted label that carries `value` at its own precision, or null.
204
+ function labelCarrying(value, seen) {
205
+ for (const label of seen) {
206
+ const m = FIGURE.exec(String(label).trim());
207
+ if (m && readings(m[3]).some((r) => carries(r, value))) return m;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * A read waterfall as its stacked-column construction (`waterfallAsStacked`):
214
+ * the bars the reader named stand on the axis, every step starts where the bar
215
+ * before it ends; the bars the image labels are labelled — a step with its
216
+ * signed change just above the bar, a total inside its end — in the image's own
217
+ * number format; connector lines only where the image draws them. Figures that
218
+ * do not bridge are built as read and reported in `notes`.
219
+ */
220
+ function waterfallJsx(e, spec, tokens, data, base, notes) {
221
+ const c = e.chart;
222
+ const values = data.series[0].values;
223
+ const small = e.w < 180 || e.h < 110;
224
+ const isCategory = (t) => data.categories.some((cat) => cat && String(cat).trim().toLowerCase() === t.trim().toLowerCase().replace(/\s+/g, " "));
225
+ const seen = [...(Array.isArray(c.labels) ? c.labels : []), ...(c.folded?.texts ?? [])].map(String).filter((t) => !isCategory(t));
226
+ // A small chart carries the labels its image paints on it (folded back), and
227
+ // no others.
228
+ const labelled = small && c.folded?.values !== true ? [] : values.map((v, i) => (labelCarrying(v, seen) ? i : -1)).filter((i) => i >= 0);
229
+ const w = waterfallAsStacked(data, { labelled });
230
+ const totals = new Set(w.totals);
231
+ const name = (i) => JSON.stringify(String(data.categories[i] ?? i + 1).replace(/\s+/g, " ").trim());
232
+ for (const m of w.mismatches) {
233
+ notes.push(`chart ${e.id}: the steps before total ${name(m.index)} end at ${m.running}, but that total reads ${m.total} — the figures do not bridge; check them against the approved plan (built as read, not corrected)`);
234
+ }
235
+ if (!w.declared) {
236
+ const later = w.totals.filter((i) => i > 0).map(name);
237
+ notes.push(`chart ${e.id}: the reader named no waterfall totals; the first bar stands on the axis${later.length ? `, and ${later.join(", ")} because ${later.length === 1 ? "it equals" : "each equals"} the running sum` : ""} — check which bars the concept draws standing on the axis`);
238
+ }
239
+ // Each sign in the format the image sets it in; a fall's bar is drawn as a
240
+ // height, so its label carries the painted minus as a literal.
241
+ const rises = values.filter((v, i) => !totals.has(i) && v >= 0);
242
+ const falls = values.filter((v, i) => !totals.has(i) && v < 0);
243
+ const riseFormat = labelNumberFormat(rises, seen);
244
+ const fallSign = falls.map((v) => labelCarrying(v, seen)?.[2]).find((s) => s === "-" || s === "−");
245
+ const fallBody = labelNumberFormat(falls.map(Math.abs), seen.map((t) => t.replace(/^(\D*?)[-−]/, "$1"))) ?? "General";
246
+ const fallFormat = fallSign ? `"${fallSign}"${fallBody}` : fallBody;
247
+ const totalFormat = labelNumberFormat(values.filter((_, i) => totals.has(i)), seen);
248
+ const label = (format, position) => ({ show: true, value: true, position, ...(format ? { numberFormat: format } : {}) });
249
+ // Each bar in its role's colour: what the reader judged it means, else a total
250
+ // is the highlight and a step a rise or a fall.
251
+ const palette = paletteOn(e, spec, tokens);
252
+ const roles = values.map((v, i) => (Array.isArray(c.roles) && c.roles[i] && c.roles[i] !== "series" ? c.roles[i] : totals.has(i) ? "highlight" : v >= 0 ? "positive" : "negative"));
253
+ const paints = palette ? paintsFor(roles, values.length, palette) : [];
254
+ const draws = Array.isArray(c.draws) ? c.draws : [];
255
+ const fill = (i) => markStyle(paints[i], draws[i]);
256
+ const style = w.data.series.map(() => ({ labels: { show: false } }));
257
+ style[w.index.base] = { fill: "none", labels: { show: false } };
258
+ style[w.index.bars] = {
259
+ labels: { show: false },
260
+ points: values
261
+ .map((_, i) => ({ idx: i, ...fill(i), ...(totals.has(i) && labelled.includes(i) ? { label: label(totalFormat, "inEnd") } : {}) }))
262
+ .filter((p) => Object.keys(p).length > 1),
263
+ };
264
+ if (w.index.below != null) {
265
+ style[w.index.below] = { labels: { show: false }, points: values.map((_, i) => ({ idx: i, ...fill(i) })).filter((p, i) => w.data.series[w.index.below].values[i] != null && Object.keys(p).length > 1) };
266
+ }
267
+ if (w.index.change != null) {
268
+ style[w.index.change] = {
269
+ fill: "none",
270
+ labels: { show: false },
271
+ points: labelled.filter((i) => !totals.has(i)).map((i) => ({ idx: i, label: label(values[i] >= 0 ? riseFormat : fallFormat, "inBase") })),
272
+ };
273
+ }
274
+ const connector = palette?.roles.reference ?? null;
275
+ for (const k of w.index.connectors) {
276
+ style[k] = { line: { ...(connector ? { color: connector } : {}), widthPt: 0.75, dash: "dash" }, marker: { symbol: "none" }, labels: { show: false } };
277
+ }
278
+ const showCategories = c.folded?.categories === true || (!small && (!Array.isArray(c.labels) || data.categories.some((cat) => cat && c.labels.some((t) => String(t).trim().toLowerCase().replace(/\s+/g, " ") === String(cat).trim().toLowerCase().replace(/\s+/g, " ")))));
279
+ // Where each bar's ink ends lowest: a total from its top down to the axis, a
280
+ // step between the running value before and after it.
281
+ let running = 0;
282
+ let lowest = Infinity;
283
+ values.forEach((v, i) => {
284
+ if (totals.has(i)) { lowest = Math.min(lowest, v); running = v; } else { lowest = Math.min(lowest, running, running + v); running += v; }
285
+ });
286
+ const value = valueAxisOf(c, lowest, notes, e.id) ?? { visible: false, majorGridlines: false };
287
+ const axes = ` axes={${JSON.stringify({ category: { visible: showCategories, majorGridlines: false }, value })}}`;
288
+ return `<B ${base} h={${e.h}}><Chart type="column" data={${JSON.stringify(w.data)}}${axes} labels={{ show: false }} legend={{ show: false, pos: "b" }} layout={${JSON.stringify({ overlap: 100, gapWidth: 60 })}} dispBlanksAs="gap" seriesStyle={${JSON.stringify(style)}} style={{ width: ${e.w}, height: ${e.h} }} /></B>`;
289
+ }
290
+
291
+ /**
292
+ * The `<B>` holding one read chart with its native `<Chart>`. `data` is the
293
+ * renderable payload (`nativeChartData`); `base` the element's id and position
294
+ * attributes; `notes` collects what the agent must check.
295
+ */
296
+ export function chartJsx(e, spec, tokens, data, base, notes = []) {
297
+ const c = e.chart;
298
+ const type = c.type;
299
+ if (type === "waterfall") return waterfallJsx(e, spec, tokens, data, base, notes);
300
+ // A thumbnail-sized chart (x28 cloud 8: four charts under 180px) cannot
301
+ // carry value labels and a legend without overlapping itself — unless its
302
+ // image paints them there (x113 QBR 1: eight 107px KPI charts, each with its
303
+ // values and quarters), folded back as the chart's own.
304
+ const small = e.w < 180 || e.h < 110;
305
+ const folded = c.folded ?? {};
306
+ const sliced = ["pie", "doughnut"].includes(type);
307
+ // A pie's legend rows that carry each slice's value ("DE–PL lane 21%") name
308
+ // the slices so: the legend lists name and value as the image does.
309
+ const legendRows = sliced && folded.legend ? legendWithValues(data, folded.texts ?? []) : null;
310
+ if (legendRows) data = { ...data, categories: legendRows };
311
+ // The design's colours by what each series (each slice of a pie; each bar of a
312
+ // single series whose bars mean different things) stands for.
313
+ const palette = paletteOn(e, spec, tokens);
314
+ const roles = Array.isArray(c.roles) ? c.roles : [];
315
+ const draws = Array.isArray(c.draws) ? c.draws : [];
316
+ const perPoint = sliced
317
+ || (data.series.length === 1 && data.categories.length > 1 && (
318
+ (roles.length === data.categories.length && new Set(roles).size > 1)
319
+ || (draws.length === data.categories.length && new Set(draws).size > 1)));
320
+ const count = perPoint ? data.categories.length : data.series.length;
321
+ const paints = palette ? paintsFor(roles, count, palette) : [];
322
+ const styled = paints.length > 0 || draws.some(hollow) || draws.some((d) => DASH[d]);
323
+ const seriesStyle = styled
324
+ ? perPoint
325
+ ? [{ points: Array.from({ length: count }, (_, idx) => ({ idx, ...markStyle(paints[idx], draws[idx]) })).filter((p) => Object.keys(p).length > 1) }]
326
+ : data.series.map((_, i) => (["line", "scatter"].includes(type) ? lineStyle(paints[i], draws[i]) : markStyle(paints[i], draws[i])))
327
+ : [];
328
+ // Labels are what the image labels. Values ride on the marks only where the
329
+ // image labels every value — and not where texts of their own already carry
330
+ // them (x67 QBR 3: a donut's centre read "48%", the chart printed "48" and
331
+ // "52" on its slices as well) — in the format the image sets them in.
332
+ // Categories show where the image names them. A spec without observed
333
+ // labels keeps the size rule alone.
334
+ // Texts the image drew as the chart's own legend, value or category labels are
335
+ // folded back into the chart (chart-labels.mjs); they are labels it shows.
336
+ const seen = Array.isArray(c.labels) || folded.texts?.length ? [...(c.labels ?? []), ...(folded.texts ?? [])].map(String) : null;
337
+ const inside = (o) => o !== e && o.text != null && o.x >= e.x - 2 && o.y >= e.y - 2 && o.x + o.w <= e.x + e.w + 2 && o.y + o.h <= e.y + e.h + 2;
338
+ const values = data.series.flatMap((sr) => sr.values).filter(Number.isFinite);
339
+ const texts = (spec.elements ?? []).filter(inside).map((o) => num(o.text));
340
+ const showValues = folded.values === true || (!small && (!seen || (values.every((v) => seen.some((t) => near(num(t), v))) && !values.some((v) => texts.some((t) => near(t, v))))));
341
+ const showCategories = folded.categories === true || (!small && (!seen || data.categories.some((cat) => cat && seen.some((t) => t.trim().toLowerCase() === String(cat).trim().toLowerCase()))));
342
+ // A category name ("Q3", "2024") carries a figure too; it is no value label.
343
+ const isCategory = (t) => data.categories.some((cat) => cat && String(cat).trim().toLowerCase() === t.trim().toLowerCase());
344
+ const format = showValues && seen ? labelNumberFormat(values, seen.filter((t) => !isCategory(t))) : null;
345
+ // A series the image names at one of its points (a target line's "Target
346
+ // 96.5%"): its last point carries its name and value, its other points none.
347
+ for (const s of folded.seriesLabels ?? []) {
348
+ const last = data.series[s]?.values.length - 1;
349
+ if (!(last >= 0)) continue;
350
+ seriesStyle[s] = seriesStyle[s] ?? {};
351
+ seriesStyle[s].labels = { show: false };
352
+ seriesStyle[s].points = [...(seriesStyle[s].points ?? []), { idx: last, label: { show: true, value: true, seriesName: true, separator: " ", ...(type === "line" ? { position: "right" } : {}), ...(format ? { numberFormat: format } : {}) } }];
353
+ }
354
+ const labels = `{ show: ${showValues}, value: ${showValues}${format ? `, numberFormat: ${q(format)}` : ""} }`;
355
+ // Mockup charts are labelled, not gridded: values ride on the marks, so the
356
+ // value axis and every gridline stay off (x55 QBR 2: default axes and
357
+ // gridlines read as "default-style plots") — unless the image paints the
358
+ // axis with its ticks, or cuts it (valueAxisOf).
359
+ const value = sliced ? null : valueAxisOf(c, Math.min(...values.map((v) => (["line", "scatter", "area"].includes(type) ? v : Math.max(v, 0)))), notes, e.id) ?? { visible: false, majorGridlines: false };
360
+ const axes = sliced ? "" : ` axes={${JSON.stringify({ category: { visible: showCategories, majorGridlines: false }, value })}}`;
361
+ const legend = legendOf(c, type, data.series.length, small, data.categories);
362
+ // A pie's slices vary: its legend then lists the slices, not the one series
363
+ // (`c:varyColors` 0 gives a single "Spend mix" entry, x108 cloud 5).
364
+ // A doughnut's hole holds what stands in it (chart-hole.mjs).
365
+ const hole = type === "doughnut" ? holeSizeFor(spec, e) : null;
366
+ if (hole && !hole.fits) notes.push(`chart ${e.id}: what stands in its hole is wider than the widest hole a doughnut draws (90% of the ring) — it runs onto the ring; make it smaller or the ring larger`);
367
+ const layout = sliced ? ` layout={{ varyColors: true${hole ? `, holeSize: ${hole.size}` : ""} }}` : "";
368
+ const style = seriesStyle.length ? ` seriesStyle={${JSON.stringify(seriesStyle.map((x) => x ?? {}))}}` : "";
369
+ const plot = sliced ? ringPlot(e, hole != null && !legend.show) : null;
370
+ const plotArea = plot ? ` plotArea={{ layout: ${JSON.stringify(plot)} }}` : "";
371
+ return `<B ${base} h={${e.h}}><Chart type=${q(type)} data={${JSON.stringify(data)}}${axes} labels={${labels}} legend={{ show: ${legend.show}, pos: ${q(legend.pos)} }}${layout}${plotArea}${style} style={{ width: ${e.w}, height: ${e.h} }} /></B>`;
372
+ }
373
+
374
+ /**
375
+ * Where a pie or doughnut draws its ring, as the plot area's manual layout
376
+ * (fractions of the chart's frame): where its frame grew to hold its folded
377
+ * legend or labels (chart-labels.mjs `folded.frame`), the ring stays the size
378
+ * and place the image painted it — left to PowerPoint, the ring shrinks and
379
+ * moves beside the native legend (x114 QBR 3: the globe in the hole sat on the
380
+ * ring's edge); and a ring with something in its hole and no legend (`whole`)
381
+ * fills its frame, so the hole is the size `holeSizeFor` gave it. null
382
+ * otherwise: PowerPoint lays the ring out.
383
+ */
384
+ function ringPlot(e, whole = false) {
385
+ const ring = ringRect(e);
386
+ if (ring.x === e.x && ring.y === e.y && ring.w === e.w && ring.h === e.h && !whole) return null;
387
+ if (!(e.w > 0 && e.h > 0 && ring.w > 0 && ring.h > 0)) return null;
388
+ const f = (v) => Math.round(Math.min(1, Math.max(0, v)) * 10000) / 10000;
389
+ return { x: f((ring.x - e.x) / e.w), y: f((ring.y - e.y) / e.h), w: f(ring.w / e.w), h: f(ring.h / e.h) };
390
+ }
391
+
392
+ /**
393
+ * A pie's slice names as its legend rows read them, with each slice's value
394
+ * where every row carries it ("DE–PL lane" + "21%" -> "DE–PL lane 21%"); null
395
+ * when the rows name the slices alone.
396
+ */
397
+ function legendWithValues(data, texts) {
398
+ const values = data.series[0]?.values ?? [];
399
+ const rows = data.categories.map((cat, i) => {
400
+ const name = labelKey(cat);
401
+ for (const t of texts) {
402
+ const key = labelKey(t);
403
+ if (!name || !key.startsWith(`${name} `)) continue;
404
+ const rest = String(t).replace(/\s+/g, " ").trim().slice(String(cat).replace(/\s+/g, " ").trim().length).trim();
405
+ if (onlyFigure(rest) && Math.abs(Math.abs(figureOf(rest)) - Math.abs(values[i])) <= 0.051 + Math.abs(values[i]) * 0.005) return `${String(cat).trim()} ${rest}`;
406
+ }
407
+ return null;
408
+ });
409
+ return rows.every(Boolean) ? rows : null;
410
+ }