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.
@@ -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. Run `slidemind logout --all`, then " +
169
- "reconnect with a fresh command from the add-in's Connect CLI panel.",
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 || !isTransientBridgeError(err)) throw err;
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
- const res = await bridgeFetch(
535
- "/api/cli/readiness",
536
- { timeoutMs: listTimeoutMs() },
537
- backendURL,
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` filters
1140
- // which storyboard beats are rendered (the CLI batches per-slide). Returns the
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(brandId, { planMarkdown, slideNumbers }, backendURL);
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?, brandStyle? }`;
1218
- // returns the server's `{ ok, image, styleReferences }` augmented with the
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, brandStyle = false, backendURL = 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
- const distinctDesigns = new Set(all.map(designKey));
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(all, deckPresFP) ??
3304
- all.find(isPaneReadyPresentation) ??
3305
- all[0];
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(all) };
3368
+ return { status: "ambiguous", ...dedupeCandidates(pool) };
3311
3369
  }
3312
3370
 
3313
3371
  // Pick a target presentation given the (possibly multi-backend) registry and an
@@ -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 sessions = readSessions().filter((s) => s.backendURL !== backendURL);
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, res.svgMarkup, "utf8");
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
- res.svgMarkup,
249
+ markup,
231
250
  res.mimeType,
232
251
  iconColor,
233
252
  recolorRefused,
@@ -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
- return String(result.json.sessionToken);
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
@@ -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
- LAYOUTS
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`);