slidemind 0.3.4 → 0.3.12
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/.agents/plugins/marketplace.json +2 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +5 -5
- package/bin/slidemind.mjs +535 -134
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +718 -127
- package/skills/search/SKILL.md +217 -57
- package/src/bridge/build-and-push.mjs +3 -3
- package/src/bridge/bundle.mjs +91 -36
- package/src/bridge/capture.mjs +34 -0
- package/src/bridge/client.mjs +81 -23
- package/src/bridge/config.mjs +21 -2
- package/src/bridge/engine-download.mjs +26 -7
- package/src/bridge/recolor.mjs +46 -0
- package/src/bridge/setup-claim.mjs +7 -1
- package/src/builders/build.mjs +179 -5
- package/src/builders/gate-server.mjs +25 -36
- package/src/builders/gate-session.js +28 -48
- package/src/builders/lint-fix.mjs +88 -0
- package/src/builders/plan-overlay.js +507 -136
- package/src/builders/plan.mjs +337 -60
- package/src/builders/review-overlay.js +126 -109
- package/src/builders/review.mjs +118 -64
- package/src/builders/storyboard-markdown.mjs +15 -0
- package/src/builders/storyboard-schema.mjs +23 -6
- package/src/builders/visual-families.mjs +20 -4
- package/src/lib/adopt.mjs +10 -6
- package/src/lib/conversions.mjs +26 -6
- package/src/lib/overwrite-guard.mjs +14 -1
- package/src/lib/reconcile.mjs +15 -4
- package/src/pptx/tags.mjs +49 -0
package/src/bridge/client.mjs
CHANGED
|
@@ -164,9 +164,15 @@ function transientRetryDelays() {
|
|
|
164
164
|
// information) from "this session is dead" (the user's problem) — see
|
|
165
165
|
// resolveSearchBackend.
|
|
166
166
|
function sessionInvalidError() {
|
|
167
|
+
// Deliberately does NOT say `logout --all`: a 401 means the add-in answered
|
|
168
|
+
// "no such session" (revoked in Settings → Connect CLI, or expired). Running
|
|
169
|
+
// `setup` with a fresh command replaces this backend's record in place, so
|
|
170
|
+
// nothing has to be wiped first — and a user with two environments who wipes
|
|
171
|
+
// everything on every 401 is how the 2026-08-27 reconnect loop happened.
|
|
167
172
|
const err = new Error(
|
|
168
|
-
"slidemind session is invalid or expired
|
|
169
|
-
"
|
|
173
|
+
"slidemind session is invalid or expired: the add-in no longer knows this session " +
|
|
174
|
+
"(revoked under Settings → General → Connect CLI, or expired). Reconnect with a fresh " +
|
|
175
|
+
"command from that panel; other environments and your Microsoft 365 sign-in stay as they are.",
|
|
170
176
|
);
|
|
171
177
|
err.status = 401;
|
|
172
178
|
err.code = "session_invalid";
|
|
@@ -191,13 +197,13 @@ function isTransientBridgeError(err) {
|
|
|
191
197
|
return /relay unreachable/i.test(String(err.message || ""));
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
async function withTransientRetry(fn) {
|
|
200
|
+
async function withTransientRetry(fn, isRetryable = isTransientBridgeError) {
|
|
195
201
|
const delays = transientRetryDelays();
|
|
196
202
|
for (let attempt = 0; ; attempt += 1) {
|
|
197
203
|
try {
|
|
198
204
|
return await fn();
|
|
199
205
|
} catch (err) {
|
|
200
|
-
if (attempt >= delays.length || !
|
|
206
|
+
if (attempt >= delays.length || !isRetryable(err)) throw err;
|
|
201
207
|
await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
|
|
202
208
|
}
|
|
203
209
|
}
|
|
@@ -531,10 +537,18 @@ async function listPresentationsForBackend(backendURL) {
|
|
|
531
537
|
async function readinessForBackend(backendURL) {
|
|
532
538
|
const env = deriveEnvLabel(backendURL);
|
|
533
539
|
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
540
|
+
// Retry only a 503: the add-in answers that (code `backend_unavailable`)
|
|
541
|
+
// while its session store cannot respond, and a 404 here means an older
|
|
542
|
+
// add-in without the readiness route, which the fallback below handles
|
|
543
|
+
// without waiting out the retry delays.
|
|
544
|
+
const res = await withTransientRetry(
|
|
545
|
+
() =>
|
|
546
|
+
bridgeFetch(
|
|
547
|
+
"/api/cli/readiness",
|
|
548
|
+
{ timeoutMs: listTimeoutMs() },
|
|
549
|
+
backendURL,
|
|
550
|
+
),
|
|
551
|
+
(err) => err?.status === 503 && err?.code !== "pane_suspended",
|
|
538
552
|
);
|
|
539
553
|
const data = (await res.json()) ?? {};
|
|
540
554
|
const presentations = Array.isArray(data.presentations)
|
|
@@ -591,6 +605,12 @@ export async function connectionStatus() {
|
|
|
591
605
|
backendURL,
|
|
592
606
|
ok: false,
|
|
593
607
|
error: r.reason?.message ? r.reason.message : String(r.reason),
|
|
608
|
+
// Machine code of the failure (`backend_unavailable`, `session_invalid`,
|
|
609
|
+
// …) so the readiness ladder can tell "retry shortly" from "re-pair".
|
|
610
|
+
code: typeof r.reason?.code === "string" ? r.reason.code : null,
|
|
611
|
+
// The HTTP status too: a proxy's 503 carries no machine code (or only a
|
|
612
|
+
// prose `error`), and the ladder must still read it as "wait".
|
|
613
|
+
status: typeof r.reason?.status === "number" ? r.reason.status : null,
|
|
594
614
|
presentations: [],
|
|
595
615
|
};
|
|
596
616
|
});
|
|
@@ -1112,13 +1132,19 @@ function conceptTimeoutMs() {
|
|
|
1112
1132
|
|
|
1113
1133
|
async function postConceptImages(
|
|
1114
1134
|
brandId,
|
|
1115
|
-
{ planMarkdown, slideNumbers },
|
|
1135
|
+
{ planMarkdown, slideNumbers, references },
|
|
1116
1136
|
backendURL,
|
|
1117
1137
|
) {
|
|
1118
1138
|
const body = { planMarkdown };
|
|
1119
1139
|
if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
|
|
1120
1140
|
body.slideNumbers = slideNumbers;
|
|
1121
1141
|
}
|
|
1142
|
+
// The user's own images, inline: `{ role: "concept" | "style", image:
|
|
1143
|
+
// <data URI>, slides?: number[], name? }`. A concept replaces the generated
|
|
1144
|
+
// mockup for its slides; a style reference leads the generation.
|
|
1145
|
+
if (Array.isArray(references) && references.length > 0) {
|
|
1146
|
+
body.references = references;
|
|
1147
|
+
}
|
|
1122
1148
|
const res = await bridgeFetch(
|
|
1123
1149
|
`/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
|
|
1124
1150
|
{
|
|
@@ -1136,8 +1162,9 @@ async function postConceptImages(
|
|
|
1136
1162
|
}
|
|
1137
1163
|
|
|
1138
1164
|
// Generate concept mockup images for a brand over the pane-free brand route.
|
|
1139
|
-
// Body `{ planMarkdown, slideNumbers? }`; the optional `slideNumbers`
|
|
1140
|
-
// which storyboard beats are rendered (the CLI batches per-slide)
|
|
1165
|
+
// Body `{ planMarkdown, slideNumbers?, references? }`; the optional `slideNumbers`
|
|
1166
|
+
// filters which storyboard beats are rendered (the CLI batches per-slide);
|
|
1167
|
+
// `references` carries the user's own concept/style images inline. Returns the
|
|
1141
1168
|
// server's `{ ok, images, slideCount }` augmented with the resolved
|
|
1142
1169
|
// `backendURL`. When `backendURL` is given it posts directly; otherwise it
|
|
1143
1170
|
// fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
|
|
@@ -1146,7 +1173,7 @@ async function postConceptImages(
|
|
|
1146
1173
|
// unknown brand, missing grant) so callers can degrade to "continue without".
|
|
1147
1174
|
export async function generateConceptImagesForBrand(
|
|
1148
1175
|
brandId,
|
|
1149
|
-
{ planMarkdown, slideNumbers = null, backendURL = null } = {},
|
|
1176
|
+
{ planMarkdown, slideNumbers = null, references = null, backendURL = null } = {},
|
|
1150
1177
|
) {
|
|
1151
1178
|
if (!brandId) {
|
|
1152
1179
|
throw new Error("generateConceptImagesForBrand: brandId is required");
|
|
@@ -1155,7 +1182,11 @@ export async function generateConceptImagesForBrand(
|
|
|
1155
1182
|
throw new Error("generateConceptImagesForBrand: planMarkdown is required");
|
|
1156
1183
|
}
|
|
1157
1184
|
if (backendURL) {
|
|
1158
|
-
return postConceptImages(
|
|
1185
|
+
return postConceptImages(
|
|
1186
|
+
brandId,
|
|
1187
|
+
{ planMarkdown, slideNumbers, references },
|
|
1188
|
+
backendURL,
|
|
1189
|
+
);
|
|
1159
1190
|
}
|
|
1160
1191
|
const sessions = sessionsInScope();
|
|
1161
1192
|
const failures = [];
|
|
@@ -1170,7 +1201,7 @@ export async function generateConceptImagesForBrand(
|
|
|
1170
1201
|
}
|
|
1171
1202
|
return await postConceptImages(
|
|
1172
1203
|
brandId,
|
|
1173
|
-
{ planMarkdown, slideNumbers },
|
|
1204
|
+
{ planMarkdown, slideNumbers, references },
|
|
1174
1205
|
s.backendURL,
|
|
1175
1206
|
);
|
|
1176
1207
|
} catch (err) {
|
|
@@ -1214,15 +1245,15 @@ async function postGenerateImage(brandId, body, backendURL) {
|
|
|
1214
1245
|
}
|
|
1215
1246
|
|
|
1216
1247
|
// Generate ONE regular image over the pane-free brand route
|
|
1217
|
-
// (`slidemind imagen`). Body `{ prompt, size?, quality
|
|
1218
|
-
// returns the server's `{ ok, image
|
|
1248
|
+
// (`slidemind imagen`). Body `{ prompt, size?, quality? }`;
|
|
1249
|
+
// returns the server's `{ ok, image }` augmented with the
|
|
1219
1250
|
// resolved `backendURL`. Fan-out and degradation mirror
|
|
1220
1251
|
// generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
|
|
1221
1252
|
// when no paired backend serves the route (older add-ins, unknown brand,
|
|
1222
1253
|
// missing grant) so callers can print the one actionable fix.
|
|
1223
1254
|
export async function generateImageForBrand(
|
|
1224
1255
|
brandId,
|
|
1225
|
-
{ prompt, size = null, quality = null,
|
|
1256
|
+
{ prompt, size = null, quality = null, backendURL = null } = {},
|
|
1226
1257
|
) {
|
|
1227
1258
|
if (!brandId) {
|
|
1228
1259
|
throw new Error("generateImageForBrand: brandId is required");
|
|
@@ -1233,7 +1264,6 @@ export async function generateImageForBrand(
|
|
|
1233
1264
|
const body = { prompt };
|
|
1234
1265
|
if (size) body.size = size;
|
|
1235
1266
|
if (quality) body.quality = quality;
|
|
1236
|
-
if (brandStyle) body.brandStyle = true;
|
|
1237
1267
|
if (backendURL) {
|
|
1238
1268
|
return postGenerateImage(brandId, body, backendURL);
|
|
1239
1269
|
}
|
|
@@ -3100,6 +3130,28 @@ export async function getPresentationContent({
|
|
|
3100
3130
|
});
|
|
3101
3131
|
}
|
|
3102
3132
|
|
|
3133
|
+
// Ask the pane whether its host document is actually still alive (the
|
|
3134
|
+
// `ping-document` op — one PowerPoint.run in the webview). This exists for
|
|
3135
|
+
// `ready`'s zombie check: a webview whose document was closed keeps its socket
|
|
3136
|
+
// (and thus its registry lease) alive, so only a host round-trip tells the
|
|
3137
|
+
// truth. Deliberately NOT engineOpWithPaneWait — a probe must fail fast, never
|
|
3138
|
+
// park in the 120s pane wait. Callers decide what a failure means; this just
|
|
3139
|
+
// returns the ack or throws the transport error.
|
|
3140
|
+
export async function pingPresentation({ presId, backendURL, timeoutMs = 12000 }) {
|
|
3141
|
+
if (!presId) throw new Error("pingPresentation: presId is required");
|
|
3142
|
+
const res = await bridgeFetch(
|
|
3143
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/ping`,
|
|
3144
|
+
{
|
|
3145
|
+
method: "POST",
|
|
3146
|
+
headers: { "content-type": "application/json" },
|
|
3147
|
+
body: JSON.stringify({}),
|
|
3148
|
+
timeoutMs,
|
|
3149
|
+
},
|
|
3150
|
+
backendURL,
|
|
3151
|
+
);
|
|
3152
|
+
return res.json(); // { ok, presId?, fileName? } | { ok: false, code, error }
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3103
3155
|
function ensurePaneAckOk(data, operation) {
|
|
3104
3156
|
if (!data || data.ok !== false) return data ?? {};
|
|
3105
3157
|
const detail = data.error || `${operation} failed`;
|
|
@@ -3294,20 +3346,26 @@ export function resolveDesignSource(
|
|
|
3294
3346
|
return { status: "ambiguous", ...dedupeCandidates(matches) };
|
|
3295
3347
|
}
|
|
3296
3348
|
|
|
3297
|
-
|
|
3349
|
+
// A live pane outranks a suspended one for the DESIGN decision: a
|
|
3350
|
+
// backgrounded — or closed and not yet purged — window must not turn the one
|
|
3351
|
+
// design the user actually has in front of them into a question. Only when
|
|
3352
|
+
// nothing is live do the suspended windows decide.
|
|
3353
|
+
const live = all.filter(isPaneReadyPresentation);
|
|
3354
|
+
const pool = live.length > 0 ? live : all;
|
|
3355
|
+
const distinctDesigns = new Set(pool.map(designKey));
|
|
3298
3356
|
if (distinctDesigns.size === 1) {
|
|
3299
3357
|
// One design open (possibly one window mirrored across backends, or several
|
|
3300
3358
|
// windows on the SAME CD). Pick via the mirror tie-break, else a live pane,
|
|
3301
3359
|
// else the first — the design is unambiguous either way.
|
|
3302
3360
|
const entry =
|
|
3303
|
-
tieBreakMatches(
|
|
3304
|
-
|
|
3305
|
-
|
|
3361
|
+
tieBreakMatches(pool, deckPresFP) ??
|
|
3362
|
+
pool.find(isPaneReadyPresentation) ??
|
|
3363
|
+
pool[0];
|
|
3306
3364
|
return { status: "resolved", entry };
|
|
3307
3365
|
}
|
|
3308
3366
|
// ≥2 distinct designs open — the only case that asks the user (the deck ref
|
|
3309
3367
|
// does NOT auto-pick; it only annotates `suggested`).
|
|
3310
|
-
return { status: "ambiguous", ...dedupeCandidates(
|
|
3368
|
+
return { status: "ambiguous", ...dedupeCandidates(pool) };
|
|
3311
3369
|
}
|
|
3312
3370
|
|
|
3313
3371
|
// Pick a target presentation given the (possibly multi-backend) registry and an
|
package/src/bridge/config.mjs
CHANGED
|
@@ -160,6 +160,7 @@ export function readSessions() {
|
|
|
160
160
|
backendURL: s.backendURL,
|
|
161
161
|
sessionToken: s.sessionToken,
|
|
162
162
|
label: typeof s.label === "string" && s.label ? s.label : deriveEnvLabel(s.backendURL),
|
|
163
|
+
...(typeof s.oid === "string" && s.oid ? { oid: s.oid } : {}),
|
|
163
164
|
...(typeof s.msGraphRefreshToken === "string" && s.msGraphRefreshToken
|
|
164
165
|
? { msGraphRefreshToken: s.msGraphRefreshToken }
|
|
165
166
|
: {}),
|
|
@@ -199,6 +200,7 @@ function writeAll(sessions, defaultBackend) {
|
|
|
199
200
|
backendURL: s.backendURL,
|
|
200
201
|
sessionToken: s.sessionToken,
|
|
201
202
|
label: s.label || deriveEnvLabel(s.backendURL),
|
|
203
|
+
...(s.oid ? { oid: s.oid } : {}),
|
|
202
204
|
...(s.msGraphRefreshToken ? { msGraphRefreshToken: s.msGraphRefreshToken } : {}),
|
|
203
205
|
})),
|
|
204
206
|
};
|
|
@@ -220,15 +222,32 @@ function writeAll(sessions, defaultBackend) {
|
|
|
220
222
|
// pairing that session with the previous user's delegated Graph token would
|
|
221
223
|
// fetch one person's content under another's session. `slidemind login` again
|
|
222
224
|
// is one command; a mismatched identity is not recoverable by the user.
|
|
223
|
-
export function upsertSession({ backendURL, sessionToken, label = null }) {
|
|
225
|
+
export function upsertSession({ backendURL, sessionToken, label = null, oid = null }) {
|
|
224
226
|
if (!backendURL || !sessionToken) {
|
|
225
227
|
throw new Error("upsertSession: backendURL and sessionToken are required");
|
|
226
228
|
}
|
|
227
|
-
const
|
|
229
|
+
const all = readSessions();
|
|
230
|
+
const previous = all.find((s) => s.backendURL === backendURL);
|
|
231
|
+
const sessions = all.filter((s) => s.backendURL !== backendURL);
|
|
232
|
+
// A re-pair replaces the add-in session, not the Microsoft 365 sign-in — but
|
|
233
|
+
// only when the SAME person re-paired: a claim can hand a shared machine to
|
|
234
|
+
// someone else, who must not inherit the previous user's delegated token.
|
|
235
|
+
// Identity is the add-in-reported `oid`; unknown on either side means drop.
|
|
236
|
+
const samePerson = Boolean(oid) && previous?.oid === oid;
|
|
237
|
+
// Dropping the field detaches the record from a keychain-stored secret; the
|
|
238
|
+
// secret itself has to go too, or the previous user's refresh token stays
|
|
239
|
+
// readable at rest for whoever shares this login keychain.
|
|
240
|
+
if (!samePerson && previous?.msGraphRefreshToken === KEYCHAIN_MARKER && keychainUsable()) {
|
|
241
|
+
keychainDelete(backendURL);
|
|
242
|
+
}
|
|
228
243
|
sessions.push({
|
|
229
244
|
backendURL,
|
|
230
245
|
sessionToken,
|
|
231
246
|
label: label || deriveEnvLabel(backendURL),
|
|
247
|
+
...(oid ? { oid } : {}),
|
|
248
|
+
...(samePerson && previous?.msGraphRefreshToken
|
|
249
|
+
? { msGraphRefreshToken: previous.msGraphRefreshToken }
|
|
250
|
+
: {}),
|
|
232
251
|
});
|
|
233
252
|
const def = getDefaultBackend() || backendURL;
|
|
234
253
|
writeAll(sessions, def);
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
imageSizeFromBuffer,
|
|
15
15
|
svgSizeFromMarkup,
|
|
16
16
|
} from "./image-dimensions.mjs";
|
|
17
|
-
import { describeRecolor } from "./recolor.mjs";
|
|
17
|
+
import { applyLocalRecolor, describeRecolor } from "./recolor.mjs";
|
|
18
18
|
|
|
19
19
|
// Every image format an engine accepts, plus the alias spellings seen in the
|
|
20
20
|
// wild. Mirrors the add-in's `searchImage` set (lib/image-formats.ts there);
|
|
@@ -151,12 +151,18 @@ function describeRecolorWithRefusal(markup, mimeType, iconColor, refusedColor) {
|
|
|
151
151
|
refusedColor ? null : (iconColor ?? null),
|
|
152
152
|
);
|
|
153
153
|
if (!refusedColor) return described;
|
|
154
|
+
// With the local bake in place (applyLocalRecolor), a surviving refusal
|
|
155
|
+
// means the glyph itself declined the colour — say why instead of sending
|
|
156
|
+
// the agent on a pane hunt.
|
|
157
|
+
const why =
|
|
158
|
+
described.mode === "multicolor"
|
|
159
|
+
? "it is a multi-tone glyph and a single colour would flatten it — left as designed."
|
|
160
|
+
: described.mode === "raster"
|
|
161
|
+
? "raster images carry their own colours."
|
|
162
|
+
: "the glyph's paint structure could not be rewritten safely.";
|
|
154
163
|
return {
|
|
155
164
|
...described,
|
|
156
|
-
note:
|
|
157
|
-
`Saved in its ORIGINAL colours — recolouring to ${refusedColor} runs in the slidemind add-in. ` +
|
|
158
|
-
"Re-run this download with --to <presId> (a PowerPoint window with the slidemind pane open) " +
|
|
159
|
-
"to bake the colour in.",
|
|
165
|
+
note: `Saved in its ORIGINAL colours — recolouring to ${refusedColor} was not applied: ${why}`,
|
|
160
166
|
};
|
|
161
167
|
}
|
|
162
168
|
|
|
@@ -218,16 +224,29 @@ async function streamOneResult({
|
|
|
218
224
|
if (res?.svgMarkup) {
|
|
219
225
|
// SVG arrives inline, not as a stream.
|
|
220
226
|
fs.rmSync(tempPath, { force: true });
|
|
227
|
+
// Window-free tint fallback: the pane-side refusal is about WHERE the
|
|
228
|
+
// rewrite runs, not whether it's possible — for currentColor/mono SVGs
|
|
229
|
+
// the bake is a deterministic local string transform. Measured impact:
|
|
230
|
+
// one sim batch carried 235 "saved in ORIGINAL colours" notes, every
|
|
231
|
+
// one an off-brand icon on the rendered slide.
|
|
232
|
+
let markup = res.svgMarkup;
|
|
233
|
+
if (recolorRefused) {
|
|
234
|
+
const local = applyLocalRecolor(markup, recolorRefused);
|
|
235
|
+
if (local.applied) {
|
|
236
|
+
markup = local.markup;
|
|
237
|
+
recolorRefused = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
221
240
|
const filename = `engine-${searchId}-${selection}.svg`;
|
|
222
241
|
const target = path.join(assetsDir, filename);
|
|
223
|
-
fs.writeFileSync(target,
|
|
242
|
+
fs.writeFileSync(target, markup, "utf8");
|
|
224
243
|
return {
|
|
225
244
|
selection,
|
|
226
245
|
path: target,
|
|
227
246
|
reference: `./assets/${filename}`,
|
|
228
247
|
mimeType: res.mimeType ?? "image/svg+xml",
|
|
229
248
|
recolor: describeRecolorWithRefusal(
|
|
230
|
-
|
|
249
|
+
markup,
|
|
231
250
|
res.mimeType,
|
|
232
251
|
iconColor,
|
|
233
252
|
recolorRefused,
|
package/src/bridge/recolor.mjs
CHANGED
|
@@ -110,3 +110,49 @@ export function describeRecolor(svgMarkup, mimeType, appliedColor = null) {
|
|
|
110
110
|
note,
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
// Bake a colour into an SVG LOCALLY — the fallback for the window-free path,
|
|
115
|
+
// where the add-in refuses the tint (`recolor_needs_host`) because its
|
|
116
|
+
// DOM-based classifier lives pane-side. For the two recolourable modes the
|
|
117
|
+
// rewrite is a deterministic string transform this module can do itself:
|
|
118
|
+
// currentColor — replace every `currentColor` paint with the hex;
|
|
119
|
+
// mono — replace every concrete fill/stroke paint with the hex.
|
|
120
|
+
// Multicolor and raster are refused (flattening a multi-tone glyph or touching
|
|
121
|
+
// raster bytes is never right). Returns { markup, applied }.
|
|
122
|
+
export function applyLocalRecolor(svgMarkup, hex) {
|
|
123
|
+
if (typeof svgMarkup !== "string" || !hex || !isSvg(null, svgMarkup)) {
|
|
124
|
+
return { markup: svgMarkup, applied: false };
|
|
125
|
+
}
|
|
126
|
+
if (hasCurrentColor(svgMarkup)) {
|
|
127
|
+
return {
|
|
128
|
+
markup: svgMarkup.replace(
|
|
129
|
+
/((?:fill|stroke)\s*[:=]\s*["']?\s*)currentColor/gi,
|
|
130
|
+
`$1${hex}`,
|
|
131
|
+
),
|
|
132
|
+
applied: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const paints = distinctPaints(svgMarkup);
|
|
136
|
+
if (paints.size >= 2 || paints.size === 0) {
|
|
137
|
+
// Multicolor: never flatten. Zero declared paints: the glyph renders in
|
|
138
|
+
// the SVG default (black) via inheritance — inject a root fill instead of
|
|
139
|
+
// rewriting nothing.
|
|
140
|
+
if (paints.size === 0) {
|
|
141
|
+
return {
|
|
142
|
+
markup: svgMarkup.replace(/<svg\b/i, `<svg fill="${hex}"`),
|
|
143
|
+
applied: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return { markup: svgMarkup, applied: false };
|
|
147
|
+
}
|
|
148
|
+
const markup = svgMarkup.replace(
|
|
149
|
+
/((?:fill|stroke)\s*[:=]\s*["']?\s*)(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+)/g,
|
|
150
|
+
(m, prefix, value) => {
|
|
151
|
+
const v = value.trim().toLowerCase();
|
|
152
|
+
if (v === "none" || v === "transparent") return m;
|
|
153
|
+
if (!/^#|^rgb|^hsl/.test(v) && !NAMED_COLORS.has(v)) return m;
|
|
154
|
+
return `${prefix}${hex}`;
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
return { markup, applied: true };
|
|
158
|
+
}
|
|
@@ -114,7 +114,13 @@ export async function redeemSetupClaim({ backendURL, secret, cliVersion }) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
if (result.status === 200 && result.json?.sessionToken) {
|
|
117
|
-
|
|
117
|
+
// `oid` (the user's directory id) arrives from add-ins that report it; an
|
|
118
|
+
// older add-in leaves it null, which the session store treats as "unknown
|
|
119
|
+
// person" — i.e. never inherit the previous Microsoft 365 sign-in.
|
|
120
|
+
return {
|
|
121
|
+
sessionToken: String(result.json.sessionToken),
|
|
122
|
+
oid: typeof result.json.oid === "string" && result.json.oid ? result.json.oid : null,
|
|
123
|
+
};
|
|
118
124
|
}
|
|
119
125
|
// Discriminate on the error CODE, not the status. A deployment that predates
|
|
120
126
|
// one-command setup has no such route, so the request falls through to that
|
package/src/builders/build.mjs
CHANGED
|
@@ -29,9 +29,12 @@
|
|
|
29
29
|
|
|
30
30
|
import { spawn } from "node:child_process";
|
|
31
31
|
import fs from "node:fs";
|
|
32
|
+
import os from "node:os";
|
|
32
33
|
import path from "node:path";
|
|
33
34
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
34
35
|
|
|
36
|
+
import { resolveBundleDirForDeck } from "../bridge/bundle.mjs";
|
|
37
|
+
import { loadChartPreview } from "../bridge/capture.mjs";
|
|
35
38
|
import { FAVICON_LINK } from "./gate-server.mjs";
|
|
36
39
|
import { lintSlidesOrThrow } from "./lint.mjs";
|
|
37
40
|
import { resolveSkillForDeck } from "./resolve-skill.mjs";
|
|
@@ -145,6 +148,98 @@ function copyDesignSystem(deckDir, skill) {
|
|
|
145
148
|
fs.writeFileSync(path.join(deckDir, "layouts.css"), cssChunks.join("\n\n"));
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
// First concrete (non-generic) family of a CSS var's font stack, or null.
|
|
152
|
+
function firstConcreteFamily(cssText, varName) {
|
|
153
|
+
const match = cssText.match(new RegExp(`--${varName}\\s*:\\s*([^;]+);`));
|
|
154
|
+
if (!match) return null;
|
|
155
|
+
const generics = new Set([
|
|
156
|
+
"sans-serif",
|
|
157
|
+
"serif",
|
|
158
|
+
"monospace",
|
|
159
|
+
"system-ui",
|
|
160
|
+
"ui-sans-serif",
|
|
161
|
+
"ui-serif",
|
|
162
|
+
"ui-monospace",
|
|
163
|
+
"cursive",
|
|
164
|
+
"fantasy",
|
|
165
|
+
]);
|
|
166
|
+
for (const part of match[1].split(",")) {
|
|
167
|
+
const family = part.trim().replace(/^['"]|['"]$/g, "");
|
|
168
|
+
if (family && !generics.has(family.toLowerCase())) return family;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Copy the materialized bundle's brand fonts next to the preview and emit the
|
|
174
|
+
// same @font-face block the server's capture page uses: one rule per FACE
|
|
175
|
+
// under its canonical family plus the CSS-token alias (--font-sans/--font-mono),
|
|
176
|
+
// with real weight/style descriptors so bold/italic render (and later measure)
|
|
177
|
+
// with the real face instead of a synthesized one. Without this the preview
|
|
178
|
+
// showed system fallbacks while capture/export used the brand face.
|
|
179
|
+
function brandFontFaces(deckDir, skill) {
|
|
180
|
+
const bundleDir = skill.bundleDir;
|
|
181
|
+
if (!bundleDir || !fs.existsSync(bundleDir)) return "";
|
|
182
|
+
let meta = {};
|
|
183
|
+
try {
|
|
184
|
+
meta = JSON.parse(
|
|
185
|
+
fs.readFileSync(path.join(bundleDir, "bundle.json"), "utf8"),
|
|
186
|
+
);
|
|
187
|
+
} catch {
|
|
188
|
+
return "";
|
|
189
|
+
}
|
|
190
|
+
const fonts = Array.isArray(meta.fonts) ? meta.fonts : [];
|
|
191
|
+
if (fonts.length === 0) return "";
|
|
192
|
+
|
|
193
|
+
let css = "";
|
|
194
|
+
try {
|
|
195
|
+
css = fs.readFileSync(path.join(deckDir, "corporate-design.css"), "utf8");
|
|
196
|
+
} catch {
|
|
197
|
+
/* alias resolution degrades to canonical families only */
|
|
198
|
+
}
|
|
199
|
+
const aliasByRole = {
|
|
200
|
+
primary: firstConcreteFamily(css, "font-sans"),
|
|
201
|
+
mono: firstConcreteFamily(css, "font-mono"),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const outFonts = path.join(deckDir, "fonts");
|
|
205
|
+
const rules = [];
|
|
206
|
+
for (const font of fonts) {
|
|
207
|
+
if (!font?.family || !font?.filename) continue;
|
|
208
|
+
const filename = path.basename(font.filename);
|
|
209
|
+
const src = path.join(bundleDir, "fonts", filename);
|
|
210
|
+
if (!fs.existsSync(src)) continue;
|
|
211
|
+
fs.mkdirSync(outFonts, { recursive: true });
|
|
212
|
+
fs.copyFileSync(src, path.join(outFonts, filename));
|
|
213
|
+
|
|
214
|
+
const weight =
|
|
215
|
+
font.variable &&
|
|
216
|
+
typeof font.variable.minWeight === "number" &&
|
|
217
|
+
typeof font.variable.maxWeight === "number"
|
|
218
|
+
? `${font.variable.minWeight} ${font.variable.maxWeight}`
|
|
219
|
+
: String(
|
|
220
|
+
typeof font.weight === "number" &&
|
|
221
|
+
font.weight >= 100 &&
|
|
222
|
+
font.weight <= 900
|
|
223
|
+
? font.weight
|
|
224
|
+
: 400,
|
|
225
|
+
);
|
|
226
|
+
const style = font.style === "italic" ? "italic" : "normal";
|
|
227
|
+
const format = filename.toLowerCase().endsWith(".otf")
|
|
228
|
+
? "opentype"
|
|
229
|
+
: "truetype";
|
|
230
|
+
const rule = (family) =>
|
|
231
|
+
`@font-face { font-family: ${JSON.stringify(family)}; ` +
|
|
232
|
+
`src: url(${JSON.stringify(`./fonts/${filename}`)}) format("${format}"); ` +
|
|
233
|
+
`font-weight: ${weight}; font-style: ${style}; font-display: block; }`;
|
|
234
|
+
rules.push(rule(font.family));
|
|
235
|
+
const alias = aliasByRole[font.role];
|
|
236
|
+
if (alias && alias.toLowerCase() !== font.family.toLowerCase()) {
|
|
237
|
+
rules.push(rule(alias));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return rules.join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
148
243
|
function loadOverrides(deckDir) {
|
|
149
244
|
const file = path.join(deckDir, "overrides.json");
|
|
150
245
|
if (!fs.existsSync(file)) return { version: 1, slides: {} };
|
|
@@ -258,6 +353,10 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
258
353
|
font-style: italic;
|
|
259
354
|
}
|
|
260
355
|
</style>
|
|
356
|
+
<style id="brand-font-faces">__BRAND_FONT_FACES__</style>
|
|
357
|
+
<!-- Chart palette for the <Chart> stand-in (from the bundle's capture engine;
|
|
358
|
+
empty when the bundle carries no chart runtime). -->
|
|
359
|
+
<style id="chart-theme-tokens">__CHART_THEME_TOKENS__</style>
|
|
261
360
|
<style>
|
|
262
361
|
html, body { margin: 0; padding: 0; height: 100%; background: #0a0a0a; overflow: hidden; }
|
|
263
362
|
:root {
|
|
@@ -470,6 +569,11 @@ __SLIDES__
|
|
|
470
569
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
471
570
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
472
571
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
572
|
+
<!-- The <Chart> stand-in runtime: the add-in's own vendored scripts, inlined
|
|
573
|
+
from the bundle's capture engine (this CLI ships no chart source). They
|
|
574
|
+
install window.SlidemindChart, which the bootstrap below binds as Chart
|
|
575
|
+
before any slide is evaluated. -->
|
|
576
|
+
__CHART_PREVIEW_SCRIPTS__
|
|
473
577
|
<script>window.__SLIDE_EDITOR_OVERRIDES__ = __OVERRIDES__;</script>
|
|
474
578
|
<script>window.SVG_ASSETS = __SVG_ASSETS__;</script>
|
|
475
579
|
<script>
|
|
@@ -500,8 +604,11 @@ __RUNTIME_APPLIER__
|
|
|
500
604
|
var nameNode = node.name;
|
|
501
605
|
// Intrinsic elements only: a lowercase JSXIdentifier. Skip
|
|
502
606
|
// components (<SlideCover>), member/namespaced names, fragments.
|
|
607
|
+
// <Chart> is the one component that opts in (as in the add-in's
|
|
608
|
+
// instrument-source-loc): it forwards the attribute onto its
|
|
609
|
+
// root div, so a review pin on a chart resolves to its tag.
|
|
503
610
|
if (!nameNode || nameNode.type !== "JSXIdentifier") return;
|
|
504
|
-
if (!/^[a-z]/.test(nameNode.name)) return;
|
|
611
|
+
if (!/^[a-z]/.test(nameNode.name) && nameNode.name !== "Chart") return;
|
|
505
612
|
for (var i = 0; i < node.attributes.length; i++) {
|
|
506
613
|
var a = node.attributes[i];
|
|
507
614
|
if (a.type === "JSXAttribute" && a.name &&
|
|
@@ -649,6 +756,14 @@ __RUNTIME_APPLIER__
|
|
|
649
756
|
}
|
|
650
757
|
});
|
|
651
758
|
|
|
759
|
+
// The Chart binding slide jsx is compiled against — installed by the
|
|
760
|
+
// bundle's chart-preview scripts above; without them a chart renders
|
|
761
|
+
// nothing, exactly as the add-in's page behaves.
|
|
762
|
+
var Chart = window.SlidemindChart
|
|
763
|
+
? window.SlidemindChart.createComponent(React)
|
|
764
|
+
: function () { return null; };
|
|
765
|
+
var slideScope = Object.assign({ Chart: Chart }, LAYOUTS);
|
|
766
|
+
|
|
652
767
|
var slides = [];
|
|
653
768
|
document.querySelectorAll('script[data-kind="slide"]').forEach(function (s) {
|
|
654
769
|
var idx = parseInt(s.dataset.index, 10);
|
|
@@ -657,7 +772,7 @@ __RUNTIME_APPLIER__
|
|
|
657
772
|
var Component = evalJsx(
|
|
658
773
|
s.textContent,
|
|
659
774
|
"slides/" + name + ".jsx",
|
|
660
|
-
|
|
775
|
+
slideScope
|
|
661
776
|
);
|
|
662
777
|
slides[idx] = { name: name, Component: Component };
|
|
663
778
|
} catch (err) {
|
|
@@ -898,6 +1013,11 @@ export async function buildDeck(deckDir, opts = {}) {
|
|
|
898
1013
|
);
|
|
899
1014
|
await lintSlidesOrThrow({ deckDir, slideFiles, layoutSources });
|
|
900
1015
|
|
|
1016
|
+
// The <Chart> stand-in runtime rides in the same bundle cache (see
|
|
1017
|
+
// loadChartPreview) — resolved after lint, which already proved the bundle
|
|
1018
|
+
// is materialized.
|
|
1019
|
+
const chartPreview = await loadChartPreview(resolveBundleDirForDeck(deckDir));
|
|
1020
|
+
|
|
901
1021
|
copyDesignSystem(deckDir, skill);
|
|
902
1022
|
|
|
903
1023
|
const layoutsBlock = layoutFiles.map(inlineLayout).join("\n");
|
|
@@ -908,11 +1028,22 @@ export async function buildDeck(deckDir, opts = {}) {
|
|
|
908
1028
|
const svgAssets = collectSvgAssets(deckDir);
|
|
909
1029
|
|
|
910
1030
|
const html = HTML_TEMPLATE.replace("__TITLE__", path.basename(deckDir))
|
|
1031
|
+
.replace("__BRAND_FONT_FACES__", brandFontFaces(deckDir, skill))
|
|
911
1032
|
.replace("__LAYOUTS__", layoutsBlock)
|
|
912
1033
|
.replace("__SLIDES__", slidesBlock)
|
|
913
1034
|
.replace("__OVERRIDES__", safeInlineJson(loadOverrides(deckDir)))
|
|
914
1035
|
.replace("__SVG_ASSETS__", safeInlineJson(svgAssets))
|
|
915
|
-
.replace("__RUNTIME_APPLIER__", safeInline(fs.readFileSync(RUNTIME_APPLIER, "utf8")))
|
|
1036
|
+
.replace("__RUNTIME_APPLIER__", safeInline(fs.readFileSync(RUNTIME_APPLIER, "utf8")))
|
|
1037
|
+
// Function replacers: script sources may contain `$&`-style patterns that a
|
|
1038
|
+
// string replacement would expand.
|
|
1039
|
+
.replace("__CHART_THEME_TOKENS__", () => (chartPreview ? chartPreview.tokensCss : ""))
|
|
1040
|
+
.replace("__CHART_PREVIEW_SCRIPTS__", () =>
|
|
1041
|
+
chartPreview
|
|
1042
|
+
? chartPreview.scripts
|
|
1043
|
+
.map((script) => ` <script>\n${safeInline(script.source)}\n </script>`)
|
|
1044
|
+
.join("\n")
|
|
1045
|
+
: "",
|
|
1046
|
+
);
|
|
916
1047
|
|
|
917
1048
|
const out = path.join(deckDir, "presentation.html");
|
|
918
1049
|
fs.writeFileSync(out, html);
|
|
@@ -965,6 +1096,35 @@ const OPENER_EXIT_GRACE_MS = 2000;
|
|
|
965
1096
|
// user to open manually unless `quiet` — callers with their own messaging (the
|
|
966
1097
|
// review/plan gates) pass quiet:true and speak for themselves.
|
|
967
1098
|
export function openInBrowser(target, { quiet = false } = {}) {
|
|
1099
|
+
// Machine-level browser kill-switch for headless simulation batches
|
|
1100
|
+
// (scripts/codex-sim.mjs writes it for the batch, removes it after): every
|
|
1101
|
+
// env/PATH/cwd channel for reaching a sim session proved lossy at least
|
|
1102
|
+
// once, so the LAST line of defense is a HOME-scoped marker no shell can
|
|
1103
|
+
// strip. Stale-guarded to 6h so a crashed batch can't silently disable the
|
|
1104
|
+
// browser for real use forever.
|
|
1105
|
+
// Two locations: the config dir, and THIS MODULE'S OWN DIRECTORY — session
|
|
1106
|
+
// shells have shown unreliable HOME/XDG resolution, but a session that runs
|
|
1107
|
+
// this code by definition resolves __dirname correctly.
|
|
1108
|
+
for (const kill of [
|
|
1109
|
+
path.join(
|
|
1110
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
|
1111
|
+
"slidemind",
|
|
1112
|
+
"suppress-browser",
|
|
1113
|
+
),
|
|
1114
|
+
path.join(__dirname, ".sim-suppress-browser"),
|
|
1115
|
+
]) {
|
|
1116
|
+
try {
|
|
1117
|
+
const age = Date.now() - fs.statSync(kill).mtimeMs;
|
|
1118
|
+
if (age < 6 * 60 * 60 * 1000) {
|
|
1119
|
+
if (!quiet) {
|
|
1120
|
+
process.stderr.write(`browser suppressed (simulation) — open ${target} manually\n`);
|
|
1121
|
+
}
|
|
1122
|
+
return Promise.resolve(false);
|
|
1123
|
+
}
|
|
1124
|
+
} catch {
|
|
1125
|
+
// marker absent — keep checking / open normally
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
968
1128
|
const chain = openerChain(target);
|
|
969
1129
|
return new Promise((resolve) => {
|
|
970
1130
|
const tryAt = (i) => {
|
|
@@ -1012,10 +1172,15 @@ function parseArgs(argv) {
|
|
|
1012
1172
|
args.deck = argv[++i];
|
|
1013
1173
|
} else if (a === "--open") {
|
|
1014
1174
|
args.open = true;
|
|
1175
|
+
} else if (a === "--fix") {
|
|
1176
|
+
args.fix = true;
|
|
1015
1177
|
} else if (a === "-h" || a === "--help") {
|
|
1016
1178
|
process.stdout.write(
|
|
1017
|
-
"Usage: slidemind build <deck> [--open]\n" +
|
|
1018
|
-
" node src/builders/build.mjs --deck <deck> [--open]\n"
|
|
1179
|
+
"Usage: slidemind build <deck> [--open] [--fix]\n" +
|
|
1180
|
+
" node src/builders/build.mjs --deck <deck> [--open] [--fix]\n" +
|
|
1181
|
+
" --fix apply the deterministic lint autofixes to slides/*.jsx before building\n" +
|
|
1182
|
+
" (pin inline-svg px size from its viewBox; letter-spacing em→px where the\n" +
|
|
1183
|
+
" block's px font-size makes the conversion exact)\n",
|
|
1019
1184
|
);
|
|
1020
1185
|
process.exit(0);
|
|
1021
1186
|
} else {
|
|
@@ -1033,6 +1198,15 @@ function parseArgs(argv) {
|
|
|
1033
1198
|
async function main() {
|
|
1034
1199
|
const args = parseArgs(process.argv.slice(2));
|
|
1035
1200
|
const deckDir = path.resolve(args.deck);
|
|
1201
|
+
if (args.fix) {
|
|
1202
|
+
const { fixDeckSlides } = await import("./lint-fix.mjs");
|
|
1203
|
+
const fixed = fixDeckSlides(deckDir);
|
|
1204
|
+
if (fixed.fixes) {
|
|
1205
|
+
process.stdout.write(
|
|
1206
|
+
`Autofixed ${fixed.fixes} lint issue(s) in ${fixed.files} slide file(s) — re-read any file before patching it.\n`,
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1036
1210
|
const { out, slideCount } = await buildDeck(deckDir);
|
|
1037
1211
|
const rel = path.relative(process.cwd(), out) || out;
|
|
1038
1212
|
process.stdout.write(`Built ${rel} (${slideCount} slides)\n`);
|