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,308 @@
|
|
|
1
|
+
// Image-intent evidence, CLI side — the local half of design decision 7.
|
|
2
|
+
//
|
|
3
|
+
// Three questions, and only three, because the other half of the answer is the
|
|
4
|
+
// add-in's:
|
|
5
|
+
//
|
|
6
|
+
// 1. **Are the sources still there?** Every `original` use resolves to bytes
|
|
7
|
+
// on disk that still hash to what the registry recorded. Purely local, so
|
|
8
|
+
// it runs host-free in `view-draft` and again before any render.
|
|
9
|
+
// 2. **Did the slide actually place them?** Counted off the CAPTURED boxes
|
|
10
|
+
// (`box.assetUseId`, set by the add-in's DOM serialiser from
|
|
11
|
+
// `data-asset-use`), never off the .jsx text: a use id in a comment, in a
|
|
12
|
+
// prop the helper never forwards, or beside an unused file is not
|
|
13
|
+
// fulfillment (guide §6, A20). The transcriber emits `use="…"` as a helper
|
|
14
|
+
// prop, so a text scan would not even find the real bindings.
|
|
15
|
+
// 3. **Did the server validate?** The verdict is the add-in's
|
|
16
|
+
// `imageIntentValidation`, consumed verbatim. This module measures no
|
|
17
|
+
// geometry, no crop, no visible intersection — those are renderer rules
|
|
18
|
+
// and they live where the renderer lives (AGENTS.md: "No CLI fallbacks for
|
|
19
|
+
// add-in-owned rules").
|
|
20
|
+
//
|
|
21
|
+
// The asymmetry between the two consumers is deliberate:
|
|
22
|
+
//
|
|
23
|
+
// * `view-draft` REPORTS. A missing source or an unplaced use is an
|
|
24
|
+
// actionable repair issue printed next to the preview, recorded as a draft
|
|
25
|
+
// defect so `review` holds — but the preview is still written, because
|
|
26
|
+
// looking at the slide is how the repair gets made.
|
|
27
|
+
// * `build-pptx` / `push` REFUSE. An unresolved obligation, a failed verdict,
|
|
28
|
+
// or a verdict that never ran all stop the build before any .pptx exists.
|
|
29
|
+
// "The validation was skipped" is NEVER success: the export gate next door
|
|
30
|
+
// may degrade to a note when the backend is unreachable, and this one may
|
|
31
|
+
// not — a skipped picture check ships a deck missing the user's picture.
|
|
32
|
+
|
|
33
|
+
import { resolveBoundImageUses } from "./spec-assets.mjs";
|
|
34
|
+
|
|
35
|
+
/** The slide number a deck file name carries, or null (unnumbered file). */
|
|
36
|
+
export function slideNumberOf(fileName) {
|
|
37
|
+
const m = /^(\d{1,3})[-_.]/.exec(String(fileName ?? ""));
|
|
38
|
+
return m ? Number(m[1]) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The distinct slide numbers a set of deck file names covers, ascending. */
|
|
42
|
+
export function slideNumbersOf(fileNames) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const file of fileNames ?? []) {
|
|
45
|
+
const n = slideNumberOf(file);
|
|
46
|
+
if (n !== null && !out.includes(n)) out.push(n);
|
|
47
|
+
}
|
|
48
|
+
return out.sort((a, b) => a - b);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const stemOf = (fileName) => String(fileName ?? "").replace(/\.[^.]+$/, "");
|
|
52
|
+
|
|
53
|
+
/** One issue as a printable draft line. */
|
|
54
|
+
export function formatIssue(issue) {
|
|
55
|
+
const where = issue.file ? stemOf(issue.file) : issue.slide ? `slide ${issue.slide}` : "deck";
|
|
56
|
+
const action = issue.action ? ` ${issue.action}` : "";
|
|
57
|
+
return `[image-intent:error] ${where} — ${issue.message}.${action}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// 1 + 2: what this machine can answer
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Every `assetUseId` the captured render actually placed: `Map<useId, count>`. */
|
|
65
|
+
export function capturedUseIds(render) {
|
|
66
|
+
const counts = new Map();
|
|
67
|
+
const walk = (box) => {
|
|
68
|
+
if (!box || typeof box !== "object") return;
|
|
69
|
+
if (typeof box.assetUseId === "string" && box.assetUseId) {
|
|
70
|
+
counts.set(box.assetUseId, (counts.get(box.assetUseId) ?? 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
for (const child of box.children ?? []) walk(child);
|
|
73
|
+
};
|
|
74
|
+
for (const box of render?.boxes ?? []) walk(box);
|
|
75
|
+
return counts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Local image-intent issues for a set of captured slides.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} deckDir
|
|
82
|
+
* @param {Array<{fileName: string, status?: string, render?: object}>} slides
|
|
83
|
+
* the `view-draft` slide records — `render` present means the slide was
|
|
84
|
+
* captured, so its bindings can be counted; absent means only the source
|
|
85
|
+
* half can be answered for it.
|
|
86
|
+
* @returns {{issues: Array, byFile: Map<string, Array>}}
|
|
87
|
+
*/
|
|
88
|
+
export function localImageIntentIssues(deckDir, slides, { planMarkdown = null } = {}) {
|
|
89
|
+
const files = (slides ?? []).map((s) => s.fileName).filter(Boolean);
|
|
90
|
+
const numbers = slideNumbersOf(files);
|
|
91
|
+
const issues = [];
|
|
92
|
+
const byFile = new Map();
|
|
93
|
+
if (!numbers.length) return { issues, byFile };
|
|
94
|
+
|
|
95
|
+
const fileForSlide = new Map();
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const n = slideNumberOf(file);
|
|
98
|
+
if (n !== null && !fileForSlide.has(n)) fileForSlide.set(n, file);
|
|
99
|
+
}
|
|
100
|
+
const push = (issue) => {
|
|
101
|
+
issues.push(issue);
|
|
102
|
+
const key = issue.file ?? "";
|
|
103
|
+
if (!byFile.has(key)) byFile.set(key, []);
|
|
104
|
+
byFile.get(key).push(issue);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// The source half. `resolveBoundImageUses` is the same resolver the
|
|
108
|
+
// transcriber places pictures with, so "the draft says it is missing" and
|
|
109
|
+
// "transcribe could not place it" are one answer, not two.
|
|
110
|
+
let bound;
|
|
111
|
+
let sourceIssues;
|
|
112
|
+
try {
|
|
113
|
+
({ bound, issues: sourceIssues } = resolveBoundImageUses(deckDir, numbers, { planMarkdown }));
|
|
114
|
+
} catch (err) {
|
|
115
|
+
push({
|
|
116
|
+
code: err?.code ?? "image_intent_invalid",
|
|
117
|
+
message: `the deck's image obligations could not be read (${err?.message ?? err})`,
|
|
118
|
+
action: "Fix state/image-assets.json or state/image-use-overrides.json, then re-run.",
|
|
119
|
+
file: null,
|
|
120
|
+
slide: null,
|
|
121
|
+
});
|
|
122
|
+
return { issues, byFile };
|
|
123
|
+
}
|
|
124
|
+
for (const issue of sourceIssues) {
|
|
125
|
+
push({
|
|
126
|
+
code: issue.code,
|
|
127
|
+
message: issue.message,
|
|
128
|
+
action:
|
|
129
|
+
issue.code === "image_asset_changed"
|
|
130
|
+
? "Re-import the new bytes as their own asset and rebind the use through the plan."
|
|
131
|
+
: "Restore or re-import the source (`slidemind assets import <deck> <file>`) and rebind the use.",
|
|
132
|
+
file: issue.slide == null ? null : fileForSlide.get(issue.slide) ?? null,
|
|
133
|
+
slide: issue.slide,
|
|
134
|
+
useId: issue.useId,
|
|
135
|
+
assetId: issue.assetId,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The placement half, per captured slide. Only originals oblige a picture.
|
|
140
|
+
const wanted = new Map();
|
|
141
|
+
for (const use of bound.values()) {
|
|
142
|
+
if (!wanted.has(use.slide)) wanted.set(use.slide, []);
|
|
143
|
+
wanted.get(use.slide).push(use);
|
|
144
|
+
}
|
|
145
|
+
for (const slide of slides ?? []) {
|
|
146
|
+
const n = slideNumberOf(slide.fileName);
|
|
147
|
+
const expected = n === null ? null : wanted.get(n);
|
|
148
|
+
if (!expected?.length) continue;
|
|
149
|
+
// Without a captured render there is nothing to count — a native .pptx
|
|
150
|
+
// slide, or one the capture could not produce. Its evidence is the server
|
|
151
|
+
// verdict's job, not a guess made here.
|
|
152
|
+
if (!slide.render?.boxes) continue;
|
|
153
|
+
const placed = capturedUseIds(slide.render);
|
|
154
|
+
for (const use of expected) {
|
|
155
|
+
const count = placed.get(use.useId) ?? 0;
|
|
156
|
+
if (count === 1) continue;
|
|
157
|
+
push(
|
|
158
|
+
count === 0
|
|
159
|
+
? {
|
|
160
|
+
code: "image_use_unresolved",
|
|
161
|
+
message:
|
|
162
|
+
`${use.displayName} is an approved original for this slide and the render places it nowhere ` +
|
|
163
|
+
`(no picture carries data-asset-use="${use.useId}")`,
|
|
164
|
+
action: `Place it: <img data-asset-use="${use.useId}" src="./${use.workspacePath}" /> — never a search result or a look-alike.`,
|
|
165
|
+
file: slide.fileName,
|
|
166
|
+
slide: n,
|
|
167
|
+
useId: use.useId,
|
|
168
|
+
assetId: use.assetId,
|
|
169
|
+
}
|
|
170
|
+
: {
|
|
171
|
+
code: "image_use_mismatch",
|
|
172
|
+
message:
|
|
173
|
+
`${use.displayName} is placed ${count} times on this slide; one use is one occurrence`,
|
|
174
|
+
action: "Remove the extra picture, or record a second use in the plan for the intended repeat.",
|
|
175
|
+
file: slide.fileName,
|
|
176
|
+
slide: n,
|
|
177
|
+
useId: use.useId,
|
|
178
|
+
assetId: use.assetId,
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { issues, byFile };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `Map<fileName, string[]>` of printable draft lines, deck-level under "". */
|
|
187
|
+
export function draftImageIntentLines(deckDir, slides, opts = {}) {
|
|
188
|
+
const { byFile } = localImageIntentIssues(deckDir, slides, opts);
|
|
189
|
+
const out = new Map();
|
|
190
|
+
for (const [file, issues] of byFile) out.set(file, issues.map(formatIssue));
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// 3: the server's verdict
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
// The request only gets this far on a backend that ADVERTISED `imageIntentV1`
|
|
199
|
+
// (the capability gate in the transport refuses the rest before sending), so a
|
|
200
|
+
// response with no verdict is that backend breaking its own contract — not an
|
|
201
|
+
// old deployment. Either way the deck is not built: nothing proved the pictures
|
|
202
|
+
// reached the slides, and a skipped check is not a pass.
|
|
203
|
+
const MISSING_VERDICT =
|
|
204
|
+
"the add-in advertises image-intent support but returned no imageIntentValidation for a deck " +
|
|
205
|
+
"that carries approved image obligations, so nothing proved the pictures reached the slides. " +
|
|
206
|
+
"A check that did not run is not a pass: this deck is not built";
|
|
207
|
+
|
|
208
|
+
const MISSING_VERDICT_ACTION =
|
|
209
|
+
"Report the backend version — it answers the capability but not the check. Nothing was written " +
|
|
210
|
+
"and the deck, its plan and its imported sources are exactly as they were.";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The verdict as the CLI reads it.
|
|
214
|
+
*
|
|
215
|
+
* `ran: false` is the fail-closed case — the request carried expectations and
|
|
216
|
+
* the response carried no verdict. It is never downgraded to a warning on the
|
|
217
|
+
* final path, and the `issues` it returns say why.
|
|
218
|
+
*
|
|
219
|
+
* The wire shape (add-in `RT/image-intent-validation.ts`, agreed 2026-09-21):
|
|
220
|
+
*
|
|
221
|
+
* {version: 1, ok, uses: [{useId, assetId, sourceSha256, slide, stem,
|
|
222
|
+
* pictureId, mediaPart, rect, fit, crop?, derivative?}],
|
|
223
|
+
* issues: [{code, message, action, useId?, assetId?, slide?, stem?}]}
|
|
224
|
+
*
|
|
225
|
+
* Only `ok` and `issues` are read here. `uses` is the evidence array (named
|
|
226
|
+
* `uses`, not `checked`), its `crop` is the EMITTED `<a:srcRect>` in OOXML
|
|
227
|
+
* 100000ths rather than the contract's `{x,y,w,h}` fractions, and `derivative`
|
|
228
|
+
* carries `{sha256, parentSha256, mediaPart, transforms}` (the `renderTransforms`
|
|
229
|
+
* names of `src/lib/image-derivatives.mjs`) — three shapes
|
|
230
|
+
* worth knowing before anything starts reading them, because none is the shape
|
|
231
|
+
* the plan-side record uses.
|
|
232
|
+
*
|
|
233
|
+
* **What the verdict does and does not prove.** It compares the bytes that
|
|
234
|
+
* actually became the `ppt/media` part against the digest the request DECLARED.
|
|
235
|
+
* It cannot see this machine, so it never proves that digest still describes a
|
|
236
|
+
* file on disk — `localImageIntentIssues` above is that half. Neither alone is
|
|
237
|
+
* the whole guarantee; the build path runs both.
|
|
238
|
+
*
|
|
239
|
+
* @param {object|null} envelope the `imageIntent` the request carried
|
|
240
|
+
* @param {object|null} result the render/translate response
|
|
241
|
+
*/
|
|
242
|
+
export function imageIntentVerdict(envelope, result) {
|
|
243
|
+
if (!envelope) return { expected: false, ran: true, ok: true, issues: [] };
|
|
244
|
+
const verdict = result?.imageIntentValidation;
|
|
245
|
+
if (!verdict || typeof verdict !== "object") {
|
|
246
|
+
return {
|
|
247
|
+
expected: true,
|
|
248
|
+
ran: false,
|
|
249
|
+
ok: false,
|
|
250
|
+
issues: [
|
|
251
|
+
{
|
|
252
|
+
code: "image_intent_unsupported",
|
|
253
|
+
message: MISSING_VERDICT,
|
|
254
|
+
action: MISSING_VERDICT_ACTION,
|
|
255
|
+
file: null,
|
|
256
|
+
slide: null,
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const raw = Array.isArray(verdict.issues) ? verdict.issues : [];
|
|
262
|
+
const issues = raw.map((issue) => ({
|
|
263
|
+
code: issue?.code ?? "image_use_mismatch",
|
|
264
|
+
message: issue?.message ?? "the rendered deck did not satisfy an image obligation",
|
|
265
|
+
action: issue?.action ?? "Fix the slide's binding and re-run the build.",
|
|
266
|
+
// The add-in attributes an issue by `stem` — the `<name>` of `<name>.jsx`,
|
|
267
|
+
// which is the key it has and we have. Taking it is what puts a server
|
|
268
|
+
// issue on the right slide in the draft instead of on the deck.
|
|
269
|
+
file: issue?.file ?? (issue?.stem ? `${issue.stem}.jsx` : null),
|
|
270
|
+
slide: Number.isInteger(issue?.slide) ? issue.slide : null,
|
|
271
|
+
useId: issue?.useId,
|
|
272
|
+
assetId: issue?.assetId,
|
|
273
|
+
}));
|
|
274
|
+
// `ok` is the verdict's own word. An `ok: true` that still carries issues is
|
|
275
|
+
// reported (they are advisory there), an `ok` that is not exactly true fails
|
|
276
|
+
// — a verdict that cannot say it passed did not pass.
|
|
277
|
+
const ok = verdict.ok === true;
|
|
278
|
+
if (!ok && issues.length === 0) {
|
|
279
|
+
issues.push({
|
|
280
|
+
code: "image_use_unresolved",
|
|
281
|
+
message: "the add-in refused the deck's image obligations without naming one",
|
|
282
|
+
action: "Re-run `slidemind view-draft <deck>` to see which slide is missing its picture.",
|
|
283
|
+
file: null,
|
|
284
|
+
slide: null,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return { expected: true, ran: true, ok, issues };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Refuse a final deliverable whose image obligations are unproven.
|
|
292
|
+
*
|
|
293
|
+
* Called on the ONE choke point every .pptx passes through, after the render
|
|
294
|
+
* and before any bytes are written or inserted, so a refusal leaves the deck
|
|
295
|
+
* exactly as it was.
|
|
296
|
+
*/
|
|
297
|
+
export function assertImageIntentSatisfied(envelope, result) {
|
|
298
|
+
const verdict = imageIntentVerdict(envelope, result);
|
|
299
|
+
if (!verdict.expected || verdict.ok) return verdict;
|
|
300
|
+
const err = new Error(
|
|
301
|
+
`this deck's image obligations are not satisfied — no .pptx was written:\n` +
|
|
302
|
+
verdict.issues.map((i) => ` ${formatIssue(i)}`).join("\n"),
|
|
303
|
+
);
|
|
304
|
+
err.code = verdict.ran ? verdict.issues[0]?.code ?? "image_use_unresolved" : "image_intent_unsupported";
|
|
305
|
+
err.imageIntentIssues = verdict.issues;
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Folding targeted-edit image uses back into an approved plan.
|
|
2
|
+
//
|
|
3
|
+
// A targeted edit records a slide's uses in `state/image-use-overrides.json`
|
|
4
|
+
// (src/lib/image-use-overrides.mjs) because there is no plan stage to approve
|
|
5
|
+
// them at. The moment a plan revision IS approved, the plan becomes the source
|
|
6
|
+
// of approved intent again — so each override whose slide the new plan still
|
|
7
|
+
// carries is written into that slide's storyboard and its entry is cleared.
|
|
8
|
+
//
|
|
9
|
+
// Three properties this exists to hold:
|
|
10
|
+
//
|
|
11
|
+
// * **Only what it supersedes is cleared.** An override whose slide the plan
|
|
12
|
+
// does not have (the slide was deleted, or the deck has no slide file for
|
|
13
|
+
// that GUID yet) is left exactly where it is. Clearing it would silently
|
|
14
|
+
// discard an obligation nobody re-approved.
|
|
15
|
+
// * **A cleared obligation is never resurrected.** An override recording an
|
|
16
|
+
// empty list writes "no uses" into the plan and is then cleared, so the
|
|
17
|
+
// next read of the plan agrees with the edit instead of restoring the uses
|
|
18
|
+
// the edit removed.
|
|
19
|
+
// * **Atomic in the order that matters.** plan.md is written first, the
|
|
20
|
+
// overrides file second, each by write-then-rename. A crash between the two
|
|
21
|
+
// leaves an entry that still describes what the plan already says — the
|
|
22
|
+
// next reconcile re-applies it to the same result. The reverse order would
|
|
23
|
+
// lose the obligation outright.
|
|
24
|
+
//
|
|
25
|
+
// Called from the plan gate's two approve paths (src/builders/plan.mjs) BEFORE
|
|
26
|
+
// the approved plan is hashed, so the recorded planHash is the hash of the plan
|
|
27
|
+
// the uses were folded into.
|
|
28
|
+
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
clearSlideOverrides,
|
|
34
|
+
readImageUseOverrides,
|
|
35
|
+
slideNumberForId,
|
|
36
|
+
} from "../lib/image-use-overrides.mjs";
|
|
37
|
+
import { replaceStoryboardSegment, splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fold every applicable override into `<deckDir>/plan.md` and clear it.
|
|
41
|
+
*
|
|
42
|
+
* Never throws for a deck that has none: a plan with no image intent is the
|
|
43
|
+
* ordinary case, and approving it must cost nothing. A registry or overrides
|
|
44
|
+
* file that does not parse is reported, not thrown — refusing a human's
|
|
45
|
+
* approval over a sidecar would strand the deck.
|
|
46
|
+
*
|
|
47
|
+
* @returns {{applied: Array<{slideId: string, slide: number, uses: number}>,
|
|
48
|
+
* kept: Array<{slideId: string, why: string}>, error: string|null}}
|
|
49
|
+
*/
|
|
50
|
+
export function reconcileImageUseOverrides(deckDir) {
|
|
51
|
+
const out = { applied: [], kept: [], error: null };
|
|
52
|
+
const planPath = path.join(deckDir, "plan.md");
|
|
53
|
+
|
|
54
|
+
let overrides;
|
|
55
|
+
try {
|
|
56
|
+
overrides = readImageUseOverrides(deckDir);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { ...out, error: err?.message ?? String(err) };
|
|
59
|
+
}
|
|
60
|
+
if (!overrides.slides.length) return out;
|
|
61
|
+
|
|
62
|
+
let markdown;
|
|
63
|
+
try {
|
|
64
|
+
markdown = fs.readFileSync(planPath, "utf8");
|
|
65
|
+
} catch {
|
|
66
|
+
// No plan to fold into: every entry stays, still governing its slide.
|
|
67
|
+
return {
|
|
68
|
+
...out,
|
|
69
|
+
kept: overrides.slides.map((entry) => ({ slideId: entry.slideId, why: "no plan.md" })),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const keep = [];
|
|
74
|
+
for (const entry of overrides.slides) {
|
|
75
|
+
const slide = slideNumberForId(deckDir, entry.slideId);
|
|
76
|
+
if (slide == null) {
|
|
77
|
+
keep.push({ slideId: entry.slideId, why: "no slide carries this id" });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Re-segment each round: every splice moves the offsets of the fences after
|
|
81
|
+
// it, and two overrides on one plan is the normal case.
|
|
82
|
+
const board = splitPlanMarkdownIntoSegments(markdown).find(
|
|
83
|
+
(segment) => segment.type === "storyboard" && segment.data?.slide === slide,
|
|
84
|
+
);
|
|
85
|
+
if (!board) {
|
|
86
|
+
keep.push({ slideId: entry.slideId, why: `the plan has no storyboard for slide ${slide}` });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const next = { ...board.data };
|
|
90
|
+
if (entry.assetUses.length) next.assetUses = entry.assetUses;
|
|
91
|
+
else delete next.assetUses;
|
|
92
|
+
markdown = replaceStoryboardSegment(markdown, board, next);
|
|
93
|
+
out.applied.push({ slideId: entry.slideId, slide, uses: entry.assetUses.length });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!out.applied.length) return { ...out, kept: keep };
|
|
97
|
+
|
|
98
|
+
// Write-then-rename, as this module's header promises: `respondPlan` hashes
|
|
99
|
+
// plan.md immediately after this returns, so a crash mid-write would leave a
|
|
100
|
+
// truncated file as the APPROVED plan — and, since clearSlideOverrides had
|
|
101
|
+
// not run, the next reconcile would re-splice into that truncation.
|
|
102
|
+
const tmp = `${planPath}.${process.pid}.sm-tmp`;
|
|
103
|
+
fs.writeFileSync(tmp, markdown);
|
|
104
|
+
fs.renameSync(tmp, planPath);
|
|
105
|
+
clearSlideOverrides(deckDir, out.applied.map((a) => a.slideId));
|
|
106
|
+
return { ...out, kept: keep };
|
|
107
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The generated text-and-shape kit of a transcribed slide: the helper
|
|
2
|
+
// definitions transcribe.mjs writes at the top of every slide (`KIT`'s B and T,
|
|
3
|
+
// then `KIT_EXTRAS`' I, L, P and R — always all of them, so an author who adds
|
|
4
|
+
// a picture, a list, a paragraph or a run finds its helper already there; x111–
|
|
5
|
+
// x113 each lost a draft round to "<R> is not a registered layout component"). They are the one owner of
|
|
6
|
+
// how a box, a text, a picture, a paragraph, a list and a run render: sizes
|
|
7
|
+
// come from ROLE, paint from FILL/TONE, list markers from the design's CSS.
|
|
8
|
+
// The slide lint holds every helper on a transcribed slide to these exact
|
|
9
|
+
// definitions (build-pptx/lint-role-type.ts): a helper edited by hand — a list
|
|
10
|
+
// whose markers it hides, a paragraph it shrinks — is refused, and a helper a
|
|
11
|
+
// slide uses without declaring it is named with its definition to restore
|
|
12
|
+
// (build-pptx/lint-kit-helpers.ts, for slides transcribed before KIT_EXTRAS).
|
|
13
|
+
// Pure; mirrored into the add-in by scripts/sync-transcription-runtime.mjs.
|
|
14
|
+
import { LIST_HELPER, RUN_HELPER } from "./list-jsx.mjs";
|
|
15
|
+
|
|
16
|
+
// I = an icon, or a picture the user supplied. Its `<img src={src}>` is
|
|
17
|
+
// dynamic; the lint checks the asset literals at its call sites, and a slide
|
|
18
|
+
// with no call site renders no image (holdout QBR 3 refused the unused helper
|
|
19
|
+
// before lint-kit-helpers.ts knew the canonical I).
|
|
20
|
+
//
|
|
21
|
+
// The five extra props are how an approved image use reaches BOTH renderers at
|
|
22
|
+
// once, and an engine icon passes none of them, so its markup is unchanged:
|
|
23
|
+
// use — `data-asset-use`, the identity the render evidence is matched on.
|
|
24
|
+
// fit — `object-fit`, which the exporter's own image-fit adapter reads.
|
|
25
|
+
// pos — `object-position`, same.
|
|
26
|
+
// crop — the approved source region on the renderer's DECLARED-crop channel
|
|
27
|
+
// (`data-sm-src-crop`, OOXML 100000ths "l,t,r,b" — the same channel a
|
|
28
|
+
// converted deck's `<a:srcRect>` rides).
|
|
29
|
+
// view — those same four numbers as an `object-view-box` inset, so the
|
|
30
|
+
// preview shows the region the .pptx will carry. A crop expressed
|
|
31
|
+
// only in CSS would export as the whole picture.
|
|
32
|
+
export const ICON_HELPER = ` const I = ({ id, x, y, w, h, src, use, fit = "contain", pos, crop, view, stack }) => (
|
|
33
|
+
<img id={id} src={src} data-asset-use={use} data-sm-src-crop={crop} data-sm-stack={stack}
|
|
34
|
+
style={{ position: "absolute", left: x, top: y, width: w, height: h, objectFit: fit,
|
|
35
|
+
...(pos ? { objectPosition: pos } : {}), ...(view ? { objectViewBox: view } : {}) }} />
|
|
36
|
+
);
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
// L = a native list (list-jsx.mjs).
|
|
40
|
+
|
|
41
|
+
// P = one paragraph of a text block (a title and the description under it).
|
|
42
|
+
// The block's T places the block; its paragraphs stack in normal flow on the
|
|
43
|
+
// design's line-height, so a longer title pushes its description down instead
|
|
44
|
+
// of running into it, and the export ships the block as ONE text frame.
|
|
45
|
+
export const PARAGRAPH_HELPER = ` const P = ({ role = "body", bold, tone = "dark", colorVar, align = "left", nowrap, indent, children }) => (
|
|
46
|
+
<div style={{ textAlign: align, ...ROLE[role], color: colorVar ?? TONE[tone], lineHeight: "var(--lh-tight)", ...(indent ? { marginLeft: indent } : {}),
|
|
47
|
+
...(nowrap ? { whiteSpace: "nowrap" } : {}), ...(bold ? { fontWeight: "var(--fw-bold)" } : {}) }}>{children}</div>
|
|
48
|
+
);
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
export const KIT = ` // Maps from the spec's vocabulary to the design's tokens.
|
|
52
|
+
const FILL = {};
|
|
53
|
+
const TONE = {};
|
|
54
|
+
const ROLE = {
|
|
55
|
+
subtitle: { fontSize: "var(--fs-caption)", color: TONE.muted },
|
|
56
|
+
section: { fontSize: "var(--fs-body-sm)", fontWeight: "var(--fw-bold)" },
|
|
57
|
+
title: { fontSize: "var(--fs-body-sm)", fontWeight: "var(--fw-bold)" },
|
|
58
|
+
body: { fontSize: "var(--fs-caption)" },
|
|
59
|
+
label: { fontSize: "var(--fs-caption)", color: TONE.muted },
|
|
60
|
+
caption: { fontSize: "var(--fs-caption)" },
|
|
61
|
+
number: { fontSize: "var(--fs-h3)", fontWeight: "var(--fw-bold)" },
|
|
62
|
+
badge: { fontSize: "var(--fs-caption)", fontWeight: "var(--fw-bold)" },
|
|
63
|
+
};
|
|
64
|
+
const RADIUS = { rounded: "var(--r-md)", pill: "var(--r-pill)", circle: "var(--r-pill)", ring: "var(--r-pill)", ellipse: "50%" };
|
|
65
|
+
// ROLE is the slide's one type hierarchy on the design's ramp, on tight
|
|
66
|
+
// leading: titles and body on one step, labels and captions one under it,
|
|
67
|
+
// numbers above. A slide whose text does not fit moves the whole ladder
|
|
68
|
+
// down one step, never one text. The transcriber never reads a size off
|
|
69
|
+
// the image. \`px\` on a T or L is the author's step off the ramp (never
|
|
70
|
+
// below the design's minimum), on PowerPoint's half-point grid.
|
|
71
|
+
const HALF_PT = (px) => Math.round(px * 1.5) / 1.5;
|
|
72
|
+
// B = a painted or positioned box. T = a text line (no height: text takes what it needs;
|
|
73
|
+
// a single-line text never wraps). I = an icon or picture. A T or an I inside
|
|
74
|
+
// a B with no x/y is seated: the B centres it (align/valign), so a glyph,
|
|
75
|
+
// digit or icon on a disc is never placed by its own coordinates. Every line — connector, rule,
|
|
76
|
+
// arc, bracket, mark — is written out in full below as a native line directive.
|
|
77
|
+
// Figure = a drawn shape as a structural sketch (circles, outline points, typed
|
|
78
|
+
// edges), compiled exactly at render: edit its points, circles and relations.
|
|
79
|
+
// prst / adj on a B: the preset a line glues to (its connection sites).
|
|
80
|
+
// fillVar / colorVar: the design token nearest the colour the image shows
|
|
81
|
+
// (snapped by the CLI from corporate-design.css); the tone word is the
|
|
82
|
+
// fallback when the design has no such colour.
|
|
83
|
+
// stack="n:r": this part belongs to row r of declared stack n ("n:gR:F" =
|
|
84
|
+
// drawn in the gap under row R, at fraction F of it); STACKS holds each
|
|
85
|
+
// stack's align and spacing. A
|
|
86
|
+
// row moves as one — move all its parts, or let preparation re-lay it.
|
|
87
|
+
// align / valign: the box's own words ride ON the shape — one PowerPoint
|
|
88
|
+
// shape with a text body, never a text box parked over it. Where they sit
|
|
89
|
+
// inside the box is what the image measured, so this works the same for a
|
|
90
|
+
// circle, a chip, a band or a card.
|
|
91
|
+
const PLACE = { left: "flex-start", top: "flex-start", center: "center", middle: "center", right: "flex-end", bottom: "flex-end" };
|
|
92
|
+
const B = ({ id, x, y, w, h, plotOwner, prst, adj, fill = "none", fillVar, shape, shadow, align, valign, stack, style, children }) => (
|
|
93
|
+
<div id={id} data-sm-plot-owner={plotOwner} data-prst={prst} data-adj={adj} data-sm-stack={stack} style={{ position: "absolute", left: x, top: y, width: w, height: h, boxSizing: "border-box",
|
|
94
|
+
background: fillVar ?? FILL[fill], borderRadius: RADIUS[shape], ...(shadow ? { boxShadow: "var(--shadow-md)" } : {}),
|
|
95
|
+
...(align || valign ? { display: "flex", justifyContent: PLACE[align] ?? "center", alignItems: PLACE[valign] ?? "center", textAlign: align ?? "center" } : {}), ...style }}>{children}</div>
|
|
96
|
+
);
|
|
97
|
+
const T = ({ id, x, y, w, h, rotation, valign, px, role = "body", bold, tone = "dark", colorVar, align = "left", nowrap, stack, block, children }) => (
|
|
98
|
+
<div id={id} data-sm-stack={stack} data-sm-block={block} style={{ position: "absolute", left: x, top: y, width: w, ...(valign === "middle" ? { height: h, display: "flex", alignItems: "center", justifyContent: "center" } : {}), textAlign: align, ...ROLE[role], color: colorVar ?? TONE[tone],
|
|
99
|
+
...(rotation ? { height: h, transform: "rotate(" + rotation + "deg)", transformOrigin: "center" } : {}),
|
|
100
|
+
...(px ? { fontSize: HALF_PT(px) } : {}), lineHeight: role === "number" ? 1 : "var(--lh-tight)",
|
|
101
|
+
...(nowrap ? { whiteSpace: "nowrap" } : {}), ...(bold ? { fontWeight: "var(--fw-bold)" } : {}) }}>{children}</div>
|
|
102
|
+
);
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
// The canonical definition of each kit helper, `const X = (…) => …;` exactly
|
|
106
|
+
// as a slide carries it, by name.
|
|
107
|
+
function definitions(src) {
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const m of src.matchAll(/^ {2}const ([A-Z]) = \([\s\S]*?\n {2}(?:\);|\};)$/gm)) out[m[1]] = m[0];
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
// The helpers after `KIT` on every transcribed slide, in this order.
|
|
113
|
+
export const KIT_EXTRAS = ICON_HELPER + LIST_HELPER + PARAGRAPH_HELPER + RUN_HELPER;
|
|
114
|
+
|
|
115
|
+
export const KIT_HELPER_SOURCES = Object.freeze({
|
|
116
|
+
...definitions(KIT),
|
|
117
|
+
...definitions(ICON_HELPER),
|
|
118
|
+
...definitions(PARAGRAPH_HELPER),
|
|
119
|
+
...definitions(LIST_HELPER),
|
|
120
|
+
...definitions(RUN_HELPER),
|
|
121
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Layout names and content slots — canonical here, mirrored byte-identically
|
|
2
|
+
// into the add-in twice: beside the transcription modules (runtime/transcription)
|
|
3
|
+
// and into the browser-safe plan contract (shared/plan-contract/builders), where
|
|
4
|
+
// the add-in's own layout-naming.ts takes its fold from. It imports nothing.
|
|
5
|
+
|
|
6
|
+
// `// [CONTENT: 1133×503 px @ (74, 171) …]` in a layout's JSX header names the
|
|
7
|
+
// content slot the slide's children are laid into.
|
|
8
|
+
// The slot's size, and where it sits on the stage (`@ (x, y)`, stage px) when
|
|
9
|
+
// the layout says so: the reader drops the template's own marks outside it.
|
|
10
|
+
export function contentSlotFromLayoutSource(src) {
|
|
11
|
+
const m = /\[CONTENT:\s*(\d+)\s*[×x]\s*(\d+)\s*px(?:\s*@\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\))?/i.exec(src ?? "");
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
return { w: Number(m[1]), h: Number(m[2]), ...(m[3] != null ? { x: Number(m[3]), y: Number(m[4]) } : {}) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A layout name folded for comparison: case, accents, spaces, hyphens and
|
|
17
|
+
// underscores do not tell two names apart.
|
|
18
|
+
export function foldLayoutName(value) {
|
|
19
|
+
return String(value).normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[\s_-]+/g, "").toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The plan's layout name against the design's own: exact, else the one it
|
|
23
|
+
// differs from only in folding (a planner's "TitleMitInhalt" is the design's
|
|
24
|
+
// "TitelMitInhalt" only if the fold says so), else null. One rule on both
|
|
25
|
+
// clients: the add-in's canonicalizer (concept-images.ts) calls this too.
|
|
26
|
+
export function canonicalLayoutName(name, names) {
|
|
27
|
+
if (!name) return null;
|
|
28
|
+
const list = [...names];
|
|
29
|
+
if (list.includes(name)) return name;
|
|
30
|
+
const folded = foldLayoutName(name);
|
|
31
|
+
return list.find((candidate) => foldLayoutName(candidate) === folded) ?? null;
|
|
32
|
+
}
|