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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
// Concept specs — the machine inventory of a mockup the add-in extracts with
|
|
2
|
+
// a vision model (`POST /api/cli/brands/:id/concept-specs`), written by
|
|
3
|
+
// `slidemind concepts` next to each PNG as `concept-slide-NN.spec.json`
|
|
4
|
+
// (machine, slot pixels) and `concept-slide-NN.spec.txt` (the outline the agent
|
|
5
|
+
// transcribes). `view-draft` counts the render against the JSON. Pure helpers
|
|
6
|
+
// only; no I/O beyond the two readers.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { nativeChartData } from "./concept-geometry.mjs";
|
|
10
|
+
import { centreHoles, holeIds, holeMembers } from "./chart-hole.mjs";
|
|
11
|
+
import { foldChartLabels } from "./chart-labels.mjs";
|
|
12
|
+
import { applyLines } from "./concept-lines.mjs";
|
|
13
|
+
import { applyAround, applySeats, seatOf } from "./seat.mjs";
|
|
14
|
+
import { applyStacks, remapGrids, remapStacks, unblockStackRows } from "./stack.mjs";
|
|
15
|
+
import { foldTextBlocks } from "./text-blocks.mjs";
|
|
16
|
+
import { cleanText, textIsPrinted, typeset } from "./concept-text.mjs";
|
|
17
|
+
export { cleanText, textIsPrinted };
|
|
18
|
+
import { fixedAspect } from "./concept-layout.mjs";
|
|
19
|
+
import { foldListMarkers } from "./concept-lists.mjs";
|
|
20
|
+
import { glyphMarkOf } from "./concept-marks.mjs";
|
|
21
|
+
import { remapRepeats } from "./repeat-groups.mjs";
|
|
22
|
+
|
|
23
|
+
// Measurement knob (scripts/fidelity): names of normalization steps to skip,
|
|
24
|
+
// so a replay can show what each one contributes. Unset in production.
|
|
25
|
+
const ablated = (name) => (process.env.SLIDEMIND_ABLATE ?? "").split(",").includes(name);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The generation of the persisted spec shape, hashed into the fingerprint a
|
|
29
|
+
* spec is cached under (design decision 6, `slidemind concepts`).
|
|
30
|
+
*
|
|
31
|
+
* Bump it when the spec gains or changes a field an already-written spec cannot
|
|
32
|
+
* carry — `image.assetUseId` was the first. Every intent-bearing spec on disk is
|
|
33
|
+
* then refused for reuse and re-read; a spec for a slide with no image uses has
|
|
34
|
+
* no fingerprint at all and stays reusable, exactly as a legacy concept does.
|
|
35
|
+
*
|
|
36
|
+
* This is NOT the model's wire format (`schemaFormat: legacy | compact-v1`):
|
|
37
|
+
* both expand to the same persisted spec server-side, so switching between them
|
|
38
|
+
* would re-spend a vision pass for an identical file.
|
|
39
|
+
*/
|
|
40
|
+
export const SPEC_SCHEMA_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One element's `image` payload, normalized (implementation guide §5).
|
|
44
|
+
*
|
|
45
|
+
* Two shapes reach this. `{query}` is what an add-in without the image-intent
|
|
46
|
+
* contract returns, and it stays valid — it comes back with `assetUseId: null`.
|
|
47
|
+
* `{query, assetUseId}` is a picture the user supplied: the bytes are resolved
|
|
48
|
+
* from the deck's registry by use id, so the binding wins and the query is
|
|
49
|
+
* dropped. Leaving the query would let the asset fetcher search for a lookalike
|
|
50
|
+
* and write it over the user's own file — the substitution the contract exists
|
|
51
|
+
* to prevent.
|
|
52
|
+
*
|
|
53
|
+
* Idempotent, so a spec re-read from disk normalizes to itself.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeImagePayload(image) {
|
|
56
|
+
if (image == null) return null;
|
|
57
|
+
const useId = typeof image.assetUseId === "string" ? image.assetUseId.trim() : "";
|
|
58
|
+
const assetUseId = useId || null;
|
|
59
|
+
return { query: assetUseId ? null : (image.query ?? null), assetUseId };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A bare colour literal, and the kinds whose text is prose. `cleanText` cannot
|
|
63
|
+
// use these — it sees a string with no element around it, and a slide may
|
|
64
|
+
// legitimately print a hex; `dropStandIns` has the element and can tell.
|
|
65
|
+
const HEX_ONLY = /^\s*#[0-9a-f]{3,8}\s*$/i;
|
|
66
|
+
const TEXT_KINDS = new Set(["text", "number"]);
|
|
67
|
+
|
|
68
|
+
// A layout's background tone from design.md's Layout Catalog entry
|
|
69
|
+
// (`"tone":"light"|"dark"` in its Selection JSON); light when unknown.
|
|
70
|
+
// Null when the layout is unknown to this bundle: the caller then trusts the
|
|
71
|
+
// mockup's own background (`spec.background`, read by the vision model).
|
|
72
|
+
export function layoutToneFor(bundleDir, layoutName) {
|
|
73
|
+
if (!bundleDir || !layoutName) return null;
|
|
74
|
+
try {
|
|
75
|
+
const md = fs.readFileSync(path.join(bundleDir, "design.md"), "utf8");
|
|
76
|
+
const start = md.indexOf(`### ${layoutName}\n`);
|
|
77
|
+
if (start < 0) return null;
|
|
78
|
+
const end = md.indexOf("\n### ", start + 4);
|
|
79
|
+
const section = md.slice(start, end < 0 ? undefined : end);
|
|
80
|
+
const m = /"tone"\s*:\s*"(light|dark)"/.exec(section);
|
|
81
|
+
return m ? m[1] : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const DARK_FILLS = new Set(["dark", "accent"]);
|
|
88
|
+
const LIGHT_FILLS = new Set(["white", "light", "muted"]);
|
|
89
|
+
// Dark or light ground: the observed fill when the spec carries it (a green
|
|
90
|
+
// status dot is dark ground for its white glyph), else the tone word.
|
|
91
|
+
export function fillTone(e) {
|
|
92
|
+
if (e?.fillHex) {
|
|
93
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(e.fillHex);
|
|
94
|
+
if (m) {
|
|
95
|
+
const n = parseInt(m[1], 16);
|
|
96
|
+
const lum = (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
|
|
97
|
+
return lum < 0.5 ? "dark" : "light";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (DARK_FILLS.has(e?.fill)) return "dark";
|
|
101
|
+
if (LIGHT_FILLS.has(e?.fill)) return "light";
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The mockup was drawn on ITS background; the slide is built on the layout's.
|
|
106
|
+
// A spec whose text is `light` because the mockup was black would vanish on a
|
|
107
|
+
// white layout (and vice versa), and a white card on a white slide has no
|
|
108
|
+
// edge. So: text tone flips against the nearest painted ancestor's fill, else
|
|
109
|
+
// the layout's tone; a card whose fill matches the background it sits on is
|
|
110
|
+
// marked `elevated` (draw it with a shadow). Idempotent.
|
|
111
|
+
export function normalizeForLayout(spec, { tone: layoutTone = null } = {}) {
|
|
112
|
+
const read = new Map((spec.elements ?? []).map((e) => [e.id, e]));
|
|
113
|
+
const relationNotes = applyLines(spec).filter((n) => !(spec.notes ?? []).includes(n));
|
|
114
|
+
if (relationNotes.length) spec.notes = [...(spec.notes ?? []), ...relationNotes];
|
|
115
|
+
// Lists first, while every mark still stands where the reader read it: an
|
|
116
|
+
// item's dot or tick goes (or the item's marker, before a carrier), the words
|
|
117
|
+
// lose a typed glyph, the items of one list share a block (concept-lists.mjs).
|
|
118
|
+
if (!ablated("foldListMarkers")) spec.elements = foldListMarkers(spec.elements ?? [], spec.connectors ?? [], spec.repeats ?? []);
|
|
119
|
+
// Relations the reader declared are built before anything reads a child's
|
|
120
|
+
// rect: a ring on its hub's centre, a child where its seat puts it (seat.mjs).
|
|
121
|
+
applyAround(spec);
|
|
122
|
+
applySeats(spec, { beforeFold: true });
|
|
123
|
+
// The layout's tone when the plan's layout exists; else the mockup's own
|
|
124
|
+
// background as the model read it; else light.
|
|
125
|
+
const tone = layoutTone ?? (spec.background === "dark" ? "dark" : "light");
|
|
126
|
+
// The plan's layout is authoritative: a concept drawn in the other tone is a
|
|
127
|
+
// concept-generation defect, said once and never answered by swapping layouts.
|
|
128
|
+
if (layoutTone && (spec.background === "dark" || spec.background === "light") && spec.background !== layoutTone) {
|
|
129
|
+
const note = `the concept was drawn on a ${spec.background} background but its planned layout is ${layoutTone}; transcribed onto the planned layout, colours adapted to it`;
|
|
130
|
+
if (!(spec.notes ?? []).includes(note)) spec.notes = [...(spec.notes ?? []), note];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
|
|
134
|
+
// The painted element a box sits on: its parent chain first, else the
|
|
135
|
+
// smallest painted element whose rect contains the box's centre (the model
|
|
136
|
+
// often lists a label as a top-level element although it sits on an arch
|
|
137
|
+
// or a bar — x33 scratch: dark text on the accent beachhead arch).
|
|
138
|
+
const painted = (spec.elements ?? []).filter((x) => x.fill && x.fill !== "none");
|
|
139
|
+
// The painted element itself, so the caller can read the exact colour a text
|
|
140
|
+
// will sit ON and not just its light/dark bucket.
|
|
141
|
+
const groundElOf = (e) => {
|
|
142
|
+
let p = e.parent ? byId.get(e.parent) : null;
|
|
143
|
+
let guard = 0;
|
|
144
|
+
while (p && guard++ < 32) {
|
|
145
|
+
if (fillTone(p)) return p;
|
|
146
|
+
p = p.parent ? byId.get(p.parent) : null;
|
|
147
|
+
}
|
|
148
|
+
const cx = e.x + e.w / 2;
|
|
149
|
+
const cy = e.y + e.h / 2;
|
|
150
|
+
let best = null;
|
|
151
|
+
for (const c of painted) {
|
|
152
|
+
if (c === e || c.kind === "icon") continue;
|
|
153
|
+
if (cx < c.x || cx > c.x + c.w || cy < c.y || cy > c.y + c.h) continue;
|
|
154
|
+
// A ground lies UNDER the element, so it is larger than it: a shield
|
|
155
|
+
// drawn inside a white disc made the disc its own ground's child and it
|
|
156
|
+
// lost the lift that kept it visible on white (x54 cloud 5).
|
|
157
|
+
if (c.w * c.h <= (e.w || 0) * (e.h || 0)) continue;
|
|
158
|
+
if (!best || c.w * c.h < best.w * best.h) best = c;
|
|
159
|
+
}
|
|
160
|
+
return best;
|
|
161
|
+
};
|
|
162
|
+
const groundOf = (e) => {
|
|
163
|
+
const g = groundElOf(e);
|
|
164
|
+
return g ? (fillTone(g) ?? "light") : tone;
|
|
165
|
+
};
|
|
166
|
+
const backgroundOf = groundOf;
|
|
167
|
+
const WORDLESS = new Set(["rule", "icon", "image", "path", "chart"]);
|
|
168
|
+
// The chrome is slide copy too (x74N1's headlines carried the concept's
|
|
169
|
+
// em-dashes); it is written as JSX attribute strings, so a double quote
|
|
170
|
+
// becomes a single one.
|
|
171
|
+
for (const [k, v] of Object.entries(spec.chrome ?? {})) if (typeof v === "string") spec.chrome[k] = typeset(v, { quote: "'" });
|
|
172
|
+
for (const e of spec.elements ?? []) {
|
|
173
|
+
e.text = cleanText(e.text, { printed: textIsPrinted(e) });
|
|
174
|
+
// A bound original survives every pass below untouched: nothing here folds,
|
|
175
|
+
// drops or re-parents a picture. Normalizing the payload once, here, is
|
|
176
|
+
// what makes that true for a spec read back from disk too.
|
|
177
|
+
if (e.kind === "image") e.image = normalizeImagePayload(e.image);
|
|
178
|
+
// A rule, icon, picture, path or chart carries no words of its own. A
|
|
179
|
+
// card, disc, badge or shape may (x34 dropped a scorecard's fourth metric
|
|
180
|
+
// by treating a card's real text as a stand-in).
|
|
181
|
+
if (e.text != null && WORDLESS.has(e.kind)) e.text = null;
|
|
182
|
+
// A painted element's own text (a badge digit) sits on its own fill.
|
|
183
|
+
const own = e.fill && e.fill !== "none" ? fillTone(e) : null;
|
|
184
|
+
const bg = backgroundOf(e);
|
|
185
|
+
if (e.text != null || e.kind === "rule") {
|
|
186
|
+
const textBg = e.text != null && own ? own : bg;
|
|
187
|
+
if (textBg === "light" && e.color === "light") e.color = "dark";
|
|
188
|
+
if (textBg === "dark" && e.color === "dark") e.color = "light";
|
|
189
|
+
// The ground a reader actually sees this text on, carried through to the
|
|
190
|
+
// writer: the tone word above is the fallback, and the observed colour
|
|
191
|
+
// has to be checked against this or a misread glyph ships unreadable
|
|
192
|
+
// (dark type on the brand accent — 2.4:1).
|
|
193
|
+
if (e.text != null) {
|
|
194
|
+
const g = own ? e : groundElOf(e);
|
|
195
|
+
e.ground = textBg;
|
|
196
|
+
e.groundHex = g?.fillHex ?? null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if ((e.kind === "card" || e.kind === "panel") && own) {
|
|
200
|
+
e.elevated = own === bg;
|
|
201
|
+
}
|
|
202
|
+
// A disc or badge painted the slide's own colour with no outline is
|
|
203
|
+
// invisible unless it keeps the lift the image drew it with (x51 GTM 1:
|
|
204
|
+
// four white loop nodes on white vanished, only their icons left).
|
|
205
|
+
if ((e.kind === "disc" || e.kind === "badge" || e.kind === "shape") && own && own === bg && !e.stroke) e.elevated = true;
|
|
206
|
+
// The reader saw whether it casts a shadow: that observation wins over
|
|
207
|
+
// the colour inference above.
|
|
208
|
+
if (typeof e.shadow === "boolean") e.elevated = e.shadow;
|
|
209
|
+
// Preserve the observed icon role. The writer checks its actual paint
|
|
210
|
+
// against the resolved background, just like text.
|
|
211
|
+
if (e.kind === "icon" && e.icon) {
|
|
212
|
+
e.icon.color ??= bg === "dark" ? "white" : "dark";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// A bold lead-in the model listed twice — once alone ("Owner:") and once as
|
|
216
|
+
// the start of the sentence at the same origin — renders as two texts on the
|
|
217
|
+
// same pixels. Fold the lead-in into the sentence as its `leadIn`.
|
|
218
|
+
const texts = (spec.elements ?? []).filter((e) => e.text != null && (e.kind === "text" || e.kind === "number"));
|
|
219
|
+
const dropped = new Set();
|
|
220
|
+
for (const a of texts) {
|
|
221
|
+
for (const b of texts) {
|
|
222
|
+
if (a === b || dropped.has(a.id) || dropped.has(b.id)) continue;
|
|
223
|
+
const near = Math.abs(a.x - b.x) <= 6 && Math.abs(a.y - b.y) <= 6;
|
|
224
|
+
const lead = String(a.text).trim().replace(/[:\s]+$/, "");
|
|
225
|
+
const bt = String(b.text).trim();
|
|
226
|
+
if (near && lead && bt.startsWith(lead) && bt.length > lead.length) {
|
|
227
|
+
b.leadIn = String(a.text).trim();
|
|
228
|
+
dropped.add(a.id);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
// A short bold run the model listed on its own ("70/100") inside a
|
|
232
|
+
// sentence it also listed: an emphasis span, not a second text.
|
|
233
|
+
const overlapsRect = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
234
|
+
if (overlapsRect && lead.length >= 2 && bt.length > lead.length + 4 && bt.includes(lead) && (a.weight === "bold" || a.role === "number")) {
|
|
235
|
+
b.emphasis = [...(b.emphasis ?? []), lead];
|
|
236
|
+
dropped.add(a.id);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (dropped.size) {
|
|
241
|
+
spec.elements = spec.elements.filter((e) => !dropped.has(e.id));
|
|
242
|
+
spec.connectors = (spec.connectors ?? []).filter((c) => !dropped.has(c.from) && !dropped.has(c.to));
|
|
243
|
+
if (spec.counts) spec.counts.elements = spec.elements.length;
|
|
244
|
+
}
|
|
245
|
+
// A connector runs over the slide background between its nodes (a white
|
|
246
|
+
// stroke over a white layout is invisible — x31 scratch: five such on one
|
|
247
|
+
// loop), unless both ends sit inside the same painted panel.
|
|
248
|
+
// A white stroke vanishes over every white card it crosses (x31 scratch:
|
|
249
|
+
// five loop edges refused for 1.00:1 contrast); the muted grey reads on a
|
|
250
|
+
// light and on a dark ground alike, so it is the light stroke's rendering.
|
|
251
|
+
for (const c of spec.connectors ?? []) {
|
|
252
|
+
if (c.color === "light") c.color = "muted";
|
|
253
|
+
if (tone === "dark" && c.color === "dark") c.color = "muted";
|
|
254
|
+
}
|
|
255
|
+
// An element that lies wholly outside the content slot cannot be drawn in
|
|
256
|
+
// it: it is chrome the concept image drew (a footer logo, a placeholder
|
|
257
|
+
// guide) that the reader listed as content. Named, then left out.
|
|
258
|
+
// The reader always maps into the slot it was given (`spec.slot`).
|
|
259
|
+
const slot = spec.slot;
|
|
260
|
+
const outside = (e) => slot && [e.x, e.y, e.w, e.h].every(Number.isFinite)
|
|
261
|
+
&& (e.x >= slot.w - 1 || e.y >= slot.h - 1 || e.x + e.w <= 1 || e.y + e.h <= 1);
|
|
262
|
+
const gone = (spec.elements ?? []).filter(outside);
|
|
263
|
+
if (gone.length) {
|
|
264
|
+
spec.elements = spec.elements.filter((e) => !outside(e));
|
|
265
|
+
spec.notes = [...(spec.notes ?? []), ...gone.map((e) => `${e.id}: lies outside the content slot (chrome the concept drew) — not drawn`)];
|
|
266
|
+
}
|
|
267
|
+
spec.elements = dropChartMarks(spec.elements ?? []);
|
|
268
|
+
// A chart's legend, category and value labels drawn beside it are its own
|
|
269
|
+
// (chart-labels.mjs); after the list fold, so a legend row's marker goes too.
|
|
270
|
+
foldChartLabels(spec);
|
|
271
|
+
if (!ablated("dropStandIns")) dropStandIns(spec.elements ?? []);
|
|
272
|
+
const beforeFold = new Set((spec.elements ?? []).map((e) => e.id));
|
|
273
|
+
if (!ablated("foldLabelsIntoShape")) spec.elements = foldLabelsIntoShape(spec.elements ?? [], { connectors: spec.connectors ?? [] });
|
|
274
|
+
// A declared stack's rows are never folded into one block (stack.mjs).
|
|
275
|
+
unblockStackRows(spec);
|
|
276
|
+
const blockNotes = [];
|
|
277
|
+
spec.elements = foldTextBlocks(spec.elements ?? [], { connectors: spec.connectors ?? [], notes: blockNotes });
|
|
278
|
+
if (blockNotes.length) spec.notes = [...(spec.notes ?? []), ...blockNotes.filter((n) => !(spec.notes ?? []).includes(n))];
|
|
279
|
+
const kept = new Set((spec.elements ?? []).map((e) => e.id));
|
|
280
|
+
// Only the ends the fold absorbed: a connector naming an element that never
|
|
281
|
+
// existed is left for the writer to report.
|
|
282
|
+
const absorbed = new Set([...beforeFold].filter((id) => !kept.has(id)));
|
|
283
|
+
spec.connectors = (spec.connectors ?? []).filter((c) => !absorbed.has(c.from) && !absorbed.has(c.to));
|
|
284
|
+
const repeatNotes = remapRepeats(spec, read).filter((n) => !(spec.notes ?? []).includes(n));
|
|
285
|
+
if (repeatNotes.length) spec.notes = [...(spec.notes ?? []), ...repeatNotes];
|
|
286
|
+
// A folded text block takes its seat as one element (seat.mjs); what stands
|
|
287
|
+
// in a doughnut's hole is centred in its ring (chart-hole.mjs); a declared
|
|
288
|
+
// stack is laid out from what survived the folds (stack.mjs).
|
|
289
|
+
applySeats(spec);
|
|
290
|
+
centreHoles(spec);
|
|
291
|
+
const stackNotes = [...remapStacks(spec, read), ...remapGrids(spec, read)].filter((n) => !(spec.notes ?? []).includes(n));
|
|
292
|
+
if (stackNotes.length) spec.notes = [...(spec.notes ?? []), ...stackNotes];
|
|
293
|
+
applyStacks(spec);
|
|
294
|
+
spec.slideTone = tone;
|
|
295
|
+
return spec;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Repetition and lowercase spelling are valid slide content. Only an
|
|
299
|
+
// explicit absence/placeholder classification is sufficient to delete copy.
|
|
300
|
+
// A chart draws its own bars, lines, axes and gridlines. The reader sometimes
|
|
301
|
+
// inventories those marks a second time as painted rectangles sitting inside
|
|
302
|
+
// the chart's own rect — 17 of them on one slide — and the writer then renders
|
|
303
|
+
// a native PowerPoint chart AND paints its bars on top of it, so every bar
|
|
304
|
+
// appears twice (reported on a built deck, cloud slide 8 at 0.58).
|
|
305
|
+
//
|
|
306
|
+
// Whatever lies wholly inside a chart belongs to that chart. Text and icons are
|
|
307
|
+
// left alone: a label or a marker over a chart may be an annotation the chart
|
|
308
|
+
// itself does not draw, and losing one is worse than a duplicate.
|
|
309
|
+
const CHART_OWNED = new Set(["bar", "rule", "shape", "path", "card", "panel", "disc", "badge"]);
|
|
310
|
+
export function dropChartMarks(elements) {
|
|
311
|
+
const charts = elements.filter((e) => e.kind === "chart" && e.w > 0 && e.h > 0 && nativeChartData(e.chart));
|
|
312
|
+
if (!charts.length) return elements;
|
|
313
|
+
// What the reader named in a doughnut's hole is drawn there (chart-hole.mjs).
|
|
314
|
+
const hole = holeMembers(elements);
|
|
315
|
+
const inside = (e, c) =>
|
|
316
|
+
e.x >= c.x - 2 && e.y >= c.y - 2 &&
|
|
317
|
+
e.x + e.w <= c.x + c.w + 2 && e.y + e.h <= c.y + c.h + 2;
|
|
318
|
+
return elements.filter((e) => {
|
|
319
|
+
if (e.kind === "chart" || !CHART_OWNED.has(e.kind) || hole.has(e.id)) return true;
|
|
320
|
+
// A labelled badge/callout is content, just like a standalone annotation.
|
|
321
|
+
// Mere containment does not establish that the chart will reproduce it.
|
|
322
|
+
if (cleanText(e.text, { printed: textIsPrinted(e) }) != null) return true;
|
|
323
|
+
return !charts.some((c) => c !== e && inside(e, c));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function dropStandIns(elements) {
|
|
328
|
+
for (const e of elements) {
|
|
329
|
+
// The reader's `placeholder` flag means different things in different
|
|
330
|
+
// places, and reading it as one thing destroyed content. On a FREE-STANDING
|
|
331
|
+
// TEXT element it is provenance: the reader describing a region instead of
|
|
332
|
+
// transcribing it ("Market column"), which must go. On a PAINTED shape it
|
|
333
|
+
// is about the slot, not the words — and acting on it there deleted 31
|
|
334
|
+
// real, visible texts across x44/x45/x46, every numbered step marker
|
|
335
|
+
// ("1".."4", "01".."04", "30\nDAYS") plus whole banner sentences, with not
|
|
336
|
+
// one genuine invention among them. Those vanishing numbers are the
|
|
337
|
+
// recurring "numbered step markers rendered as solid blue circles without
|
|
338
|
+
// visible numbers" defect.
|
|
339
|
+
//
|
|
340
|
+
// The two are cleanly separable: of 347 flagged elements in those runs not
|
|
341
|
+
// one was a text kind — they were all discs, badges, cards and panels. So
|
|
342
|
+
// honour the flag where it is provenance and ignore it where it is not,
|
|
343
|
+
// splitting on the same TEXT_KINDS the paint rule below already uses. The
|
|
344
|
+
// stand-in grammar the reader really emits for "no text" ("/null", ">",
|
|
345
|
+
// "There is no text.") is removed by `cleanText` before this runs, by
|
|
346
|
+
// shape rather than by list, so nothing here is the only guard.
|
|
347
|
+
if (e.textSource === "placeholder" && TEXT_KINDS.has(e.kind)) e.text = null;
|
|
348
|
+
|
|
349
|
+
// The one case that IS paint rather than words: a bare
|
|
350
|
+
// colour literal on a painted box is that box's paint written where its
|
|
351
|
+
// words go. All eleven in x40 sat on a shape whose own fill they named — a
|
|
352
|
+
// white card reading "#FFFFFF", an accent disc "#0000FF" — and rendered as
|
|
353
|
+
// giant hex across the slide. A hex a slide really prints is a text
|
|
354
|
+
// element, and keeps its words.
|
|
355
|
+
if (e.text != null && !TEXT_KINDS.has(e.kind) && HEX_ONLY.test(String(e.text))) e.text = null;
|
|
356
|
+
// The reader's own machinery leaking into a shape's words: its schema's
|
|
357
|
+
// URL or definition path, or a refusal (x48/x52: "http://json-schema.org/
|
|
358
|
+
// schema#", "/definitions/concept_spec_compact_v1", "stroke", "As a
|
|
359
|
+
// language model, I cannot comply"). Agents stripped these by hand.
|
|
360
|
+
if (e.text != null && !TEXT_KINDS.has(e.kind) && SCHEMA_LEAK.test(String(e.text).trim())) e.text = null;
|
|
361
|
+
if (e.text != null && REFUSAL.test(String(e.text))) e.text = null;
|
|
362
|
+
}
|
|
363
|
+
return elements;
|
|
364
|
+
}
|
|
365
|
+
// Unambiguous only: a bare word ("stroke") may be a slide's own copy, and
|
|
366
|
+
// repetition is not evidence (see the "slide canvas" test).
|
|
367
|
+
const SCHEMA_LEAK = /^(?:https?:\/\/json-schema\.org\S*|\S*\/definitions\/\S*|#?\/?(?:\$schema|\$ref)\b\S*)$/i;
|
|
368
|
+
const REFUSAL = /\b(?:as an? (?:ai|language model)|i (?:cannot|can't|am unable to|'m unable to) (?:comply|help|assist))\b/i;
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
// A painted box whose only content is text IS a text-bearing shape — a circle,
|
|
372
|
+
// a chip, a pill, a band, a card, it makes no difference. The reader lists the
|
|
373
|
+
// words as their own elements, so the writer used to park a text box on top of
|
|
374
|
+
// the shape: two objects in PowerPoint where the design has one, and the words
|
|
375
|
+
// drift the moment anyone moves the box. Those lines are folded into the shape
|
|
376
|
+
// as its own text body and the children are dropped.
|
|
377
|
+
//
|
|
378
|
+
// What stops a fold is lost content or geometry: a box holding an icon,
|
|
379
|
+
// a chart or any non-text organism is a composition, and so is one whose texts
|
|
380
|
+
// play different parts (a hero number above a caption, a card title above its
|
|
381
|
+
// body). Where they belong stays where the image put them.
|
|
382
|
+
//
|
|
383
|
+
// The alignment comes from the geometry the reader measured — the text block's
|
|
384
|
+
// margins inside its box — so a centred label centres and a paragraph in a
|
|
385
|
+
// card keeps its corner.
|
|
386
|
+
export function foldLabelsIntoShape(elements, { connectors = [] } = {}) {
|
|
387
|
+
const referenced = new Set(connectors.flatMap((c) => [c.from, c.to]));
|
|
388
|
+
const byId = new Map(elements.map((e) => [e.id, e]));
|
|
389
|
+
const kids = new Map();
|
|
390
|
+
for (const e of elements) {
|
|
391
|
+
if (!e.parent) continue;
|
|
392
|
+
if (!kids.has(e.parent)) kids.set(e.parent, []);
|
|
393
|
+
kids.get(e.parent).push(e);
|
|
394
|
+
}
|
|
395
|
+
const folded = new Set();
|
|
396
|
+
for (const [pid, children] of kids) {
|
|
397
|
+
if (children.some(e => e.textRotation)) continue;
|
|
398
|
+
const p = byId.get(pid);
|
|
399
|
+
if (!p || p.text != null) continue;
|
|
400
|
+
// These kinds use dedicated emitters and cannot carry native shape text.
|
|
401
|
+
if (["icon", "image", "chart", "path", "rule", "table", "text", "number"].includes(p.kind)) continue;
|
|
402
|
+
// Only a PAINTED box can carry text: an unfilled region is a layout area.
|
|
403
|
+
if (!p.fill || p.fill === "none") continue;
|
|
404
|
+
if (!(p.w > 0) || !(p.h > 0)) continue;
|
|
405
|
+
if (!children.length) continue;
|
|
406
|
+
// An icon or a picture can never BE a shape's text, so it does not stop
|
|
407
|
+
// the words from being one — it just has to keep out of their way, which
|
|
408
|
+
// is what the centring guard below decides.
|
|
409
|
+
const words = children.filter((c) => (c.kind === "text" || c.kind === "number") && c.text != null);
|
|
410
|
+
const others = children.filter((c) => !words.includes(c));
|
|
411
|
+
if (!words.length || others.length) continue;
|
|
412
|
+
// A fold must preserve references, list semantics, rich text and styles.
|
|
413
|
+
if (words.some((c) => referenced.has(c.id) || kids.has(c.id) || c.leadIn || c.emphasis?.length || c.paragraphs || c.marker
|
|
414
|
+
|| /(?:^|\n)\s*(?:[•·▪◦‣\-–*✓✔]|\d+[.)])\s+/.test(c.text))) continue;
|
|
415
|
+
const signature = (c) => JSON.stringify([c.colorToken, c.colorHex, c.color, c.align]);
|
|
416
|
+
if (!words.every((c) => signature(c) === signature(words[0]))) continue;
|
|
417
|
+
if (others.some((c) => c.text != null)) continue;
|
|
418
|
+
const role = words[0].role ?? null;
|
|
419
|
+
const weight = words[0].weight ?? null;
|
|
420
|
+
if (!words.every((c) => (c.role ?? null) === role && (c.weight ?? null) === weight)) continue;
|
|
421
|
+
const lines = [...words].sort((a, b) => a.y - b.y);
|
|
422
|
+
// …and they have to be one BLOCK. Consecutive lines sit about a line apart;
|
|
423
|
+
// a real gap between them means two things in one box (a title up top, a
|
|
424
|
+
// body lower down), and joining those would swallow the space the design
|
|
425
|
+
// put there.
|
|
426
|
+
const contiguous = lines.every((c, i) => {
|
|
427
|
+
if (i === 0) return true;
|
|
428
|
+
const prev = lines[i - 1];
|
|
429
|
+
const gap = c.y - (prev.y + prev.h);
|
|
430
|
+
return gap >= 0 && gap <= 1.1 * Math.max(prev.h, c.h)
|
|
431
|
+
&& Math.abs(c.x - prev.x) <= Math.min(c.w, prev.w) * 0.05
|
|
432
|
+
&& Math.abs(c.w - prev.w) <= Math.min(c.w, prev.w) * 0.05;
|
|
433
|
+
});
|
|
434
|
+
if (!contiguous) continue;
|
|
435
|
+
// Where the block sits in its box decides how the shape holds its text.
|
|
436
|
+
const x0 = Math.min(...lines.map((c) => c.x));
|
|
437
|
+
const x1 = Math.max(...lines.map((c) => c.x + c.w));
|
|
438
|
+
const y0 = Math.min(...lines.map((c) => c.y));
|
|
439
|
+
const y1 = Math.max(...lines.map((c) => c.y + c.h));
|
|
440
|
+
const near = (a, b, span) => Math.abs(a - b) <= Math.max(4, 0.12 * span);
|
|
441
|
+
const left = x0 - p.x, right = p.x + p.w - x1;
|
|
442
|
+
const top = y0 - p.y, bottom = p.y + p.h - y1;
|
|
443
|
+
if ([left, right, top, bottom].some((v) => v < 0)) continue;
|
|
444
|
+
// Native text uses the same content rectangle, not the entire parent. A
|
|
445
|
+
// block that sits in the middle of its box within the reading's
|
|
446
|
+
// tolerance IS centred there: the few pixels the reader drifted do not
|
|
447
|
+
// become asymmetric padding.
|
|
448
|
+
// A round shape, pill or badge holding nothing but its words (a badge's
|
|
449
|
+
// digit) holds them in its middle, however far the reading drifted.
|
|
450
|
+
const round = (fixedAspect(p) || p.shape === "pill" || p.kind === "badge") && !others.length;
|
|
451
|
+
// The seat the reader declared for the words decides where they sit
|
|
452
|
+
// (seat.mjs); the measured margins stand in only where none was declared.
|
|
453
|
+
const seat = seatOf(lines[0]);
|
|
454
|
+
const declared = seat && lines.every((c) => JSON.stringify(seatOf(c)) === JSON.stringify(seat)) ? seat : null;
|
|
455
|
+
const centred = declared ? declared.x === "center" && ["middle", "span"].includes(declared.y) : (near(left, right, p.w) && near(top, bottom, p.h)) || round;
|
|
456
|
+
p.textInsets = centred ? { left: 0, right: 0, top: 0, bottom: 0 } : { left, right, top, bottom };
|
|
457
|
+
// The horizontal alignment is the reader's word, never measured margins;
|
|
458
|
+
// a round shape, pill or badge holds its words in its middle.
|
|
459
|
+
p.align = round ? "center" : declared ? { left: "left", center: "center", right: "right", span: "center" }[declared.x] ?? lines[0].align ?? "left" : lines[0].align ?? (centred ? "center" : "left");
|
|
460
|
+
p.valign = declared ? { top: "top", middle: "middle", bottom: "bottom", span: "middle" }[declared.y] ?? "middle" : centred || near(top, bottom, p.h) ? "middle" : top <= bottom ? "top" : "bottom";
|
|
461
|
+
// Folding hands the words the whole box to lay out in. That is safe when
|
|
462
|
+
// nothing else is in there, and when something is — an icon above them —
|
|
463
|
+
// only if the words are centred, because a block pinned to one edge would
|
|
464
|
+
// be dragged across whatever shares the box.
|
|
465
|
+
if (others.length && p.align !== "center") continue;
|
|
466
|
+
p.text = lines.map((c) => String(c.text)).join("\n");
|
|
467
|
+
p.role = role;
|
|
468
|
+
p.weight = weight;
|
|
469
|
+
p.color = lines[0].color ?? p.color;
|
|
470
|
+
p.colorHex = lines[0].colorHex ?? null;
|
|
471
|
+
p.colorToken = lines[0].colorToken ?? null;
|
|
472
|
+
if ("ground" in lines[0]) p.ground = lines[0].ground;
|
|
473
|
+
if ("groundHex" in lines[0]) p.groundHex = lines[0].groundHex;
|
|
474
|
+
for (const c of lines) folded.add(c.id);
|
|
475
|
+
}
|
|
476
|
+
if (!folded.size) return elements;
|
|
477
|
+
return elements.filter((e) => !folded.has(e.id));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
// Text blocks (title + body, the pieces of a sentence) fold in text-blocks.mjs.
|
|
483
|
+
export { foldTextBlocks } from "./text-blocks.mjs";
|
|
484
|
+
|
|
485
|
+
export { canonicalLayoutName, contentSlotFromLayoutSource, foldLayoutName } from "./layout-names.mjs";
|
|
486
|
+
export { layoutSlotNote, plannedLayoutSlot, specAuthorNotes, unplacedUseNotes, unresolvedUseNote } from "./slide-prep.mjs";
|
|
487
|
+
|
|
488
|
+
// The spec is a machine intermediate, not a document: it lives under the
|
|
489
|
+
// deck's state/, nobody edits it, and the slide JSX is the only thing a
|
|
490
|
+
// person or an agent touches (2026-09-20: the `.spec.txt` outline and the
|
|
491
|
+
// spec-editing workflow were retired — "the output thrice").
|
|
492
|
+
export function specFileFor(deckDir, slideNumber) {
|
|
493
|
+
return path.join(deckDir, "state", "concept-specs", `concept-slide-${String(slideNumber).padStart(2, "0")}.json`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function readSpec(deckDir, slideNumber) {
|
|
497
|
+
const candidates = [
|
|
498
|
+
specFileFor(deckDir, slideNumber),
|
|
499
|
+
// Decks specced before the move kept it next to the PNG.
|
|
500
|
+
path.join(deckDir, "concepts", `concept-slide-${String(slideNumber).padStart(2, "0")}.spec.json`),
|
|
501
|
+
];
|
|
502
|
+
for (const f of candidates) {
|
|
503
|
+
try {
|
|
504
|
+
return JSON.parse(fs.readFileSync(f, "utf8"));
|
|
505
|
+
} catch {}
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function writeSpec(deckDir, slideNumber, spec) {
|
|
511
|
+
const file = specFileFor(deckDir, slideNumber);
|
|
512
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
513
|
+
fs.writeFileSync(file, JSON.stringify(spec, null, 2) + "\n");
|
|
514
|
+
return file;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function listSpecSlides(deckDir) {
|
|
518
|
+
const out = new Set();
|
|
519
|
+
for (const dir of [path.join(deckDir, "state", "concept-specs"), path.join(deckDir, "concepts")]) {
|
|
520
|
+
try {
|
|
521
|
+
for (const f of fs.readdirSync(dir)) {
|
|
522
|
+
const m = /^concept-slide-(\d+)(?:\.spec)?\.json$/.exec(f);
|
|
523
|
+
if (m) out.add(Number(m[1]));
|
|
524
|
+
}
|
|
525
|
+
} catch {}
|
|
526
|
+
}
|
|
527
|
+
return [...out].sort((a, b) => a - b);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// The spec's counts as the draft check's expectation: `{ arrows, charts, icons }`.
|
|
531
|
+
export function expectedCountsFromSpec(spec) {
|
|
532
|
+
const normalized = spec?.elements ? structuredClone(spec) : spec;
|
|
533
|
+
if (normalized?.elements) applyLines(normalized);
|
|
534
|
+
const c = normalized?.counts;
|
|
535
|
+
if (!c) return null;
|
|
536
|
+
const elements = normalized.elements ?? [];
|
|
537
|
+
const kept = new Set(foldListMarkers(structuredClone(elements), normalized.connectors ?? [], normalized.repeats ?? []).map((e) => e.id));
|
|
538
|
+
// Only the icons the list fold took in come off the icon count; a dot drawn
|
|
539
|
+
// as a disc was never one.
|
|
540
|
+
const foldedIcons = elements.filter((e) => e.kind === "icon" && !kept.has(e.id)).length;
|
|
541
|
+
// A chart-like illustration without readable numbers is NOT an obligation
|
|
542
|
+
// to fabricate a native chart. This is the same eligibility test the writer
|
|
543
|
+
// uses before replacing the observed marks.
|
|
544
|
+
const charts = elements.filter(e => e.kind === "chart" && e.w > 0 && e.h > 0 && nativeChartData(e.chart)).length;
|
|
545
|
+
// Marks the transcriber draws rather than fetches or glues are shapes, not
|
|
546
|
+
// icons or connectors, in the render.
|
|
547
|
+
const drawnMarks = elements.filter((e) => e.kind === "icon" && glyphMarkOf(e.icon?.query, e.icon?.keywords ?? [])).length;
|
|
548
|
+
// A link the reader listed twice is drawn once. `arrows` counts headed
|
|
549
|
+
// connectors only (applyLines), so only headed repeats come off it.
|
|
550
|
+
const end = (id, p) => id ?? JSON.stringify(p ?? null);
|
|
551
|
+
const headed = (normalized.connectors ?? []).filter((x) => (x.arrow ?? "end") !== "none");
|
|
552
|
+
const repeats = headed.length - new Set(headed.map((x) => `${end(x.from, x.fromPoint)}>${end(x.to, x.toPoint)}`)).size;
|
|
553
|
+
return { arrows: Math.max(0, (c.arrows ?? 0) - repeats), charts, icons: Math.max(0, (c.icons ?? 0) - foldedIcons - drawnMarks) };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const q = (s) => JSON.stringify(s);
|
|
557
|
+
|
|
558
|
+
// One line per element, children indented under their parent — what the agent
|
|
559
|
+
// reads instead of the picture. Geometry is slot pixels, origin top-left of
|
|
560
|
+
// the content slot; every token in the line maps onto a JSX attribute.
|
|
561
|
+
export function specOutline(spec, { slide } = {}) {
|
|
562
|
+
const lines = [];
|
|
563
|
+
lines.push(
|
|
564
|
+
`slide ${slide ?? "?"} · content slot ${spec.slot.w}×${spec.slot.h}px (origin = slot top-left; the mockup's body was mapped onto it) · slide background ${spec.slideTone ?? "light"}`,
|
|
565
|
+
);
|
|
566
|
+
const ch = spec.chrome ?? {};
|
|
567
|
+
const chrome = [];
|
|
568
|
+
if (ch.kicker) chrome.push(`kicker ${q(ch.kicker)}`);
|
|
569
|
+
if (ch.headline) chrome.push(`headline ${q(ch.headline)}`);
|
|
570
|
+
if (ch.footer) chrome.push(`footer ${q(ch.footer)}`);
|
|
571
|
+
lines.push(`chrome (layout props): ${chrome.join(" · ") || "none read"}`);
|
|
572
|
+
lines.push("");
|
|
573
|
+
lines.push("id kind fill shape [shadow] @ x,y wxh text role weight tone align [fillToken colorToken] [icon:query colour | image:query | asset-use:useid | chart]");
|
|
574
|
+
const byParent = new Map();
|
|
575
|
+
for (const e of spec.elements ?? []) {
|
|
576
|
+
const k = e.parent ?? "";
|
|
577
|
+
if (!byParent.has(k)) byParent.set(k, []);
|
|
578
|
+
byParent.get(k).push(e);
|
|
579
|
+
}
|
|
580
|
+
const fmt = (e) => {
|
|
581
|
+
const parts = [e.id, e.kind];
|
|
582
|
+
if (e.fill && e.fill !== "none") parts.push(e.fill);
|
|
583
|
+
if (e.shape && e.shape !== "rect") parts.push(e.shape);
|
|
584
|
+
if (e.elevated) parts.push("shadow");
|
|
585
|
+
parts.push(`@ ${e.x},${e.y} ${e.w}x${e.h}`);
|
|
586
|
+
const text = cleanText(e.text, { printed: textIsPrinted(e) });
|
|
587
|
+
// A text block: its paragraphs in order, each with its own role; a list
|
|
588
|
+
// item is named by its marker (the design draws the glyph).
|
|
589
|
+
if (e.paragraphs?.length) parts.push(`block[${e.paragraphs.map((p) => [p.marker ? `${p.marker}-item` : null, q(p.text), p.role, p.weight === "bold" ? "bold" : null].filter(Boolean).join(" ")).join(" | ")}]`);
|
|
590
|
+
else if (text != null) parts.push(e.marker ? `${e.marker}-item ${q(text)}` : q(text));
|
|
591
|
+
if (e.leadIn) parts.push(`lead-in:${q(e.leadIn)}`);
|
|
592
|
+
if (e.role && !e.paragraphs?.length) parts.push(e.role);
|
|
593
|
+
if (e.weight === "bold" && !e.paragraphs?.length) parts.push("bold");
|
|
594
|
+
if (e.color) parts.push(e.color);
|
|
595
|
+
if (e.align && e.align !== "left") parts.push(e.align);
|
|
596
|
+
for (const key of ["fillToken", "colorToken"]) if (e[key]) parts.push(`${key}:${q(e[key])}`);
|
|
597
|
+
// A bound original is named by the use id the slide's `data-asset-use` must
|
|
598
|
+
// carry — never by a search query, which it does not have.
|
|
599
|
+
if (e.image?.assetUseId) parts.push(`asset-use:${q(e.image.assetUseId)}`);
|
|
600
|
+
else if (e.image?.query) parts.push(`image:${q(e.image.query)}`);
|
|
601
|
+
if (e.icon) parts.push(`icon:${q(e.icon.query)} ${e.icon.color}`);
|
|
602
|
+
if (e.points?.length) parts.push(`points:${e.points.map((p) => `${p.x},${p.y}`).join(" ")}`);
|
|
603
|
+
if (e.geometry) parts.push(`geometry:${JSON.stringify(e.geometry)}`);
|
|
604
|
+
if (e.chart) {
|
|
605
|
+
const series = (e.chart.series ?? [])
|
|
606
|
+
.map((s) => `${q(s.name)}=[${(s.values ?? []).join(",")}]`)
|
|
607
|
+
.join(" ");
|
|
608
|
+
parts.push(
|
|
609
|
+
`chart:${e.chart.type} categories=[${(e.chart.categories ?? []).map(q).join(",")}] ${series} labels=[${(e.chart.labels ?? []).map(q).join(",")}]` +
|
|
610
|
+
(e.chart.type === "waterfall" ? ` totals=[${(e.chart.totals ?? []).join(",")}]${e.chart.connectors ? " connectors" : ""}` : "") +
|
|
611
|
+
(holeIds(e).length ? ` hole=[${holeIds(e).join(",")}]` : ""),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
return parts.join(" ");
|
|
615
|
+
};
|
|
616
|
+
const seen = new Set();
|
|
617
|
+
const emit = (parentKey, depth) => {
|
|
618
|
+
for (const e of byParent.get(parentKey) ?? []) {
|
|
619
|
+
if (seen.has(e.id)) continue;
|
|
620
|
+
seen.add(e.id);
|
|
621
|
+
lines.push(`${" ".repeat(depth)}${fmt(e)}`);
|
|
622
|
+
emit(e.id, depth + 1);
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
emit("", 0);
|
|
626
|
+
// Elements whose parent chain never reaches a root (defensive).
|
|
627
|
+
for (const e of spec.elements ?? []) if (!seen.has(e.id)) lines.push(fmt(e));
|
|
628
|
+
lines.push("");
|
|
629
|
+
const cons = spec.connectors ?? [];
|
|
630
|
+
lines.push(
|
|
631
|
+
cons.length
|
|
632
|
+
? `connectors (${cons.length}): ${cons
|
|
633
|
+
.map((c) => `${c.from ?? `(${Math.round(c.fromPoint?.x)},${Math.round(c.fromPoint?.y)})`} -> ${c.to ?? `(${Math.round(c.toPoint?.x)},${Math.round(c.toPoint?.y)})`} ${c.route} arrow=${c.arrow} ${c.style} ${c.color}`)
|
|
634
|
+
.join(" · ")}`
|
|
635
|
+
: "connectors: none",
|
|
636
|
+
);
|
|
637
|
+
const c = spec.counts ?? {};
|
|
638
|
+
lines.push(
|
|
639
|
+
`counts: cards=${c.cards ?? 0} icons=${c.icons ?? 0} arrows=${c.arrows ?? 0} charts=${c.charts ?? 0} elements=${c.elements ?? 0}`,
|
|
640
|
+
);
|
|
641
|
+
if (spec.repeats?.length) lines.push(`repeats: ${spec.repeats.map((g) => `${g.members.map((m) => (Array.isArray(m) ? `(${m.join(" + ")})` : m)).join(", ")} (${g.arrangement}${g.hub ? ` around ${g.hub}` : ""})`).join(" · ")} — each set is one organism: one size, one inner layout`);
|
|
642
|
+
if (spec.notes?.length) lines.push(`notes: ${spec.notes.join(" · ")}`);
|
|
643
|
+
lines.push(`END OF SPEC — ${spec.elements?.length ?? 0} elements, ${spec.connectors?.length ?? 0} connectors. Full JSON remains authoritative for all fields.`);
|
|
644
|
+
return lines.join("\n") + "\n";
|
|
645
|
+
}
|