slidemind 0.3.5 → 0.3.13

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.
@@ -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, loadConnectorPreview } 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: {} };
@@ -228,6 +323,12 @@ const HTML_TEMPLATE = `<!doctype html>
228
323
  --sw-tech-blue-80: #3333FF;
229
324
  --sw-magenta: #E6007E;
230
325
  }
326
+ /* A data-connector div is a directive, not a painted box (the render
327
+ derives the line between its two referenced nodes). Same rule the
328
+ bundle's corporate-design.css carries; repeated here so a preview
329
+ against an older bundle lays out like the render will. */
330
+ [data-connector] { position: absolute; width: 0; height: 0; padding: 0; margin: 0; border-color: transparent; pointer-events: none; }
331
+ [data-sm-connector-overlay] { position: absolute; left: 0; top: 0; width: 1280px; height: 720px; overflow: visible; pointer-events: none; z-index: 2147483000; }
231
332
  </style>
232
333
  <style>
233
334
  /* Substitute Arial with Arimo (a metric clone) so wrap behaviour
@@ -258,6 +359,10 @@ const HTML_TEMPLATE = `<!doctype html>
258
359
  font-style: italic;
259
360
  }
260
361
  </style>
362
+ <style id="brand-font-faces">__BRAND_FONT_FACES__</style>
363
+ <!-- Chart palette for the <Chart> stand-in (from the bundle's capture engine;
364
+ empty when the bundle carries no chart runtime). -->
365
+ <style id="chart-theme-tokens">__CHART_THEME_TOKENS__</style>
261
366
  <style>
262
367
  html, body { margin: 0; padding: 0; height: 100%; background: #0a0a0a; overflow: hidden; }
263
368
  :root {
@@ -470,6 +575,15 @@ __SLIDES__
470
575
  <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
471
576
  <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
472
577
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
578
+ <!-- The <Chart> stand-in runtime: the add-in's own vendored scripts, inlined
579
+ from the bundle's capture engine (this CLI ships no chart source). They
580
+ install window.SlidemindChart, which the bootstrap below binds as Chart
581
+ before any slide is evaluated. -->
582
+ __CHART_PREVIEW_SCRIPTS__
583
+ <!-- The exporter's connector geometry (connector-derive), inlined from the
584
+ bundle's capture engine: the overlay below derives every data-connector
585
+ route and arrowhead exactly as the .pptx export does. -->
586
+ __CONNECTOR_PREVIEW_SCRIPT__
473
587
  <script>window.__SLIDE_EDITOR_OVERRIDES__ = __OVERRIDES__;</script>
474
588
  <script>window.SVG_ASSETS = __SVG_ASSETS__;</script>
475
589
  <script>
@@ -500,8 +614,11 @@ __RUNTIME_APPLIER__
500
614
  var nameNode = node.name;
501
615
  // Intrinsic elements only: a lowercase JSXIdentifier. Skip
502
616
  // components (<SlideCover>), member/namespaced names, fragments.
617
+ // <Chart> is the one component that opts in (as in the add-in's
618
+ // instrument-source-loc): it forwards the attribute onto its
619
+ // root div, so a review pin on a chart resolves to its tag.
503
620
  if (!nameNode || nameNode.type !== "JSXIdentifier") return;
504
- if (!/^[a-z]/.test(nameNode.name)) return;
621
+ if (!/^[a-z]/.test(nameNode.name) && nameNode.name !== "Chart") return;
505
622
  for (var i = 0; i < node.attributes.length; i++) {
506
623
  var a = node.attributes[i];
507
624
  if (a.type === "JSXAttribute" && a.name &&
@@ -649,6 +766,14 @@ __RUNTIME_APPLIER__
649
766
  }
650
767
  });
651
768
 
769
+ // The Chart binding slide jsx is compiled against — installed by the
770
+ // bundle's chart-preview scripts above; without them a chart renders
771
+ // nothing, exactly as the add-in's page behaves.
772
+ var Chart = window.SlidemindChart
773
+ ? window.SlidemindChart.createComponent(React)
774
+ : function () { return null; };
775
+ var slideScope = Object.assign({ Chart: Chart }, LAYOUTS);
776
+
652
777
  var slides = [];
653
778
  document.querySelectorAll('script[data-kind="slide"]').forEach(function (s) {
654
779
  var idx = parseInt(s.dataset.index, 10);
@@ -657,7 +782,7 @@ __RUNTIME_APPLIER__
657
782
  var Component = evalJsx(
658
783
  s.textContent,
659
784
  "slides/" + name + ".jsx",
660
- LAYOUTS
785
+ slideScope
661
786
  );
662
787
  slides[idx] = { name: name, Component: Component };
663
788
  } catch (err) {
@@ -680,6 +805,130 @@ __RUNTIME_APPLIER__
680
805
 
681
806
  smInlineSvgAssets();
682
807
 
808
+ // Connector overlay — the preview's stand-in for the native connectors
809
+ // the render derives from every data-connector directive (the capture
810
+ // engine injects an equivalent SVG before its screenshot). Straight,
811
+ // elbow and arc routes, side pins, arrowheads, dash and colour follow
812
+ // the same rules as the exporter's connector-derive; the exporter's
813
+ // measured output at view-draft / in the .pptx stays the truth.
814
+ function smDrawConnectors(container) {
815
+ if (!container) return;
816
+ var old = container.querySelector(":scope > svg[data-sm-connector-overlay]");
817
+ if (old) old.remove();
818
+ var dirs = container.querySelectorAll("[data-connector]");
819
+ if (!dirs.length) return;
820
+ var derive = window.SlidemindConnectorDerive;
821
+ if (!derive || typeof derive.resolveConnector !== "function") {
822
+ console.warn("connector overlay skipped: the bundle's capture engine exports no connector geometry (re-fetch the bundle)");
823
+ return;
824
+ }
825
+ var cRect = container.getBoundingClientRect();
826
+ var scale = cRect.width / 1280 || 1;
827
+ var NS = "http://www.w3.org/2000/svg";
828
+ var svg = document.createElementNS(NS, "svg");
829
+ svg.setAttribute("data-sm-connector-overlay", "1");
830
+ svg.setAttribute("viewBox", "0 0 1280 720");
831
+ var defs = document.createElementNS(NS, "defs");
832
+ svg.appendChild(defs);
833
+ var rectOf = function (el) {
834
+ var r = el.getBoundingClientRect();
835
+ return { x: (r.left - cRect.left) / scale, y: (r.top - cRect.top) / scale, w: r.width / scale, h: r.height / scale };
836
+ };
837
+ var resolveRef = function (ref) {
838
+ if (!ref) return null;
839
+ try { return container.querySelector(ref.charAt(0) === "#" ? ref : "#" + ref); } catch (e) { return null; }
840
+ };
841
+ var sideOrNull = function (raw) {
842
+ var s = (raw || "").toLowerCase();
843
+ return (s === "top" || s === "bottom" || s === "left" || s === "right") ? s : null;
844
+ };
845
+ // Computed CSS colour ("rgb(a)(...)") → { hex, alpha } as the serialiser reports it.
846
+ var colorOf = function (css) {
847
+ var m = /rgba?\(([^)]+)\)/.exec(css || "");
848
+ if (!m) return { hex: "000000", alpha: 1 };
849
+ var p = m[1].split(",").map(function (v) { return parseFloat(v); });
850
+ var hex = p.slice(0, 3).map(function (v) {
851
+ var h = Math.max(0, Math.min(255, Math.round(v))).toString(16);
852
+ return h.length < 2 ? "0" + h : h;
853
+ }).join("").toUpperCase();
854
+ return { hex: hex, alpha: p.length > 3 && isFinite(p[3]) ? p[3] : 1 };
855
+ };
856
+ dirs.forEach(function (el, idx) {
857
+ var fromEl = resolveRef(el.getAttribute("data-from"));
858
+ var toEl = resolveRef(el.getAttribute("data-to"));
859
+ var route = (el.getAttribute("data-route") || "straight").toLowerCase();
860
+ var centerEl = route === "arc" ? resolveRef(el.getAttribute("data-center")) : null;
861
+ var arrowRaw = (el.getAttribute("data-arrow") || "none").toLowerCase();
862
+ var cs = getComputedStyle(el);
863
+ var stroke = colorOf(cs.color);
864
+ // The same payload the serialiser hands the exporter; resolveConnector
865
+ // derives endpoints, route, bends, arc, and arrowhead size from it.
866
+ var r = derive.resolveConnector({
867
+ from: el.getAttribute("data-from") || "",
868
+ to: el.getAttribute("data-to") || "",
869
+ arrow: (arrowRaw === "start" || arrowRaw === "end" || arrowRaw === "both") ? arrowRaw : "none",
870
+ route: route,
871
+ fromSide: sideOrNull(el.getAttribute("data-from-side")),
872
+ toSide: sideOrNull(el.getAttribute("data-to-side")),
873
+ center: el.getAttribute("data-center") || null,
874
+ fromRect: fromEl ? rectOf(fromEl) : null,
875
+ toRect: toEl ? rectOf(toEl) : null,
876
+ centerRect: centerEl ? rectOf(centerEl) : null,
877
+ strokeHex: stroke.hex,
878
+ strokeAlpha: stroke.alpha,
879
+ strokeWidthPx: parseFloat(cs.borderTopWidth) || 0,
880
+ dashed: /dashed|dotted/.test(cs.borderTopStyle || ""),
881
+ missing: [],
882
+ });
883
+ if (!r) return;
884
+ var color = "#" + r.strokeHex;
885
+ var d;
886
+ if (r.arc) {
887
+ var large = Math.abs(r.arc.sweepDeg) > 180 ? 1 : 0;
888
+ var sweep = r.arc.sweepDeg > 0 ? 1 : 0;
889
+ d = "M" + r.start.x + " " + r.start.y + " A" + r.arc.r + " " + r.arc.r + " 0 " + large + " " + sweep + " " + r.end.x + " " + r.end.y;
890
+ } else {
891
+ d = r.points.map(function (p, i) { return (i ? "L" : "M") + p.x + " " + p.y; }).join(" ");
892
+ }
893
+ var path = document.createElementNS(NS, "path");
894
+ path.setAttribute("d", d);
895
+ path.setAttribute("fill", "none");
896
+ path.setAttribute("stroke", color);
897
+ path.setAttribute("stroke-opacity", String(r.strokeAlpha));
898
+ path.setAttribute("stroke-width", String(r.strokeWidthPx));
899
+ if (r.dashed) {
900
+ var dash = Math.max(4, r.strokeWidthPx * 3);
901
+ path.setAttribute("stroke-dasharray", dash + " " + dash);
902
+ }
903
+ if (r.arrowStart || r.arrowEnd) {
904
+ // Marker sized to the head PowerPoint renders (r.arrowheadPx).
905
+ var markerId = "sm-conn-arrow-" + idx;
906
+ var marker = document.createElementNS(NS, "marker");
907
+ marker.setAttribute("id", markerId);
908
+ marker.setAttribute("viewBox", "0 0 10 10");
909
+ marker.setAttribute("refX", "9");
910
+ marker.setAttribute("refY", "5");
911
+ marker.setAttribute("markerWidth", String(r.arrowheadPx));
912
+ marker.setAttribute("markerHeight", String(r.arrowheadPx));
913
+ marker.setAttribute("orient", "auto-start-reverse");
914
+ marker.setAttribute("markerUnits", "userSpaceOnUse");
915
+ var tri = document.createElementNS(NS, "path");
916
+ tri.setAttribute("d", "M0,0 L10,5 L0,10 z");
917
+ tri.setAttribute("fill", color);
918
+ tri.setAttribute("fill-opacity", String(r.strokeAlpha));
919
+ marker.appendChild(tri);
920
+ defs.appendChild(marker);
921
+ if (r.arrowStart) path.setAttribute("marker-start", "url(#" + markerId + ")");
922
+ if (r.arrowEnd) path.setAttribute("marker-end", "url(#" + markerId + ")");
923
+ }
924
+ svg.appendChild(path);
925
+ });
926
+ if (svg.childNodes.length > 1) container.appendChild(svg);
927
+ }
928
+ function smScheduleConnectors(container) {
929
+ requestAnimationFrame(function () { requestAnimationFrame(function () { smDrawConnectors(container); }); });
930
+ }
931
+
683
932
  if (printMode) {
684
933
  document.body.classList.add("deck--print");
685
934
  var printDeck = document.createElement("div");
@@ -699,6 +948,7 @@ __RUNTIME_APPLIER__
699
948
  ReactDOM.createRoot(page).render(
700
949
  React.createElement(slide.Component, { assetsPath: "./assets" })
701
950
  );
951
+ smScheduleConnectors(page);
702
952
  });
703
953
  return;
704
954
  }
@@ -788,6 +1038,7 @@ __RUNTIME_APPLIER__
788
1038
  root.render(
789
1039
  React.createElement(slide.Component, { assetsPath: "./assets" })
790
1040
  );
1041
+ smScheduleConnectors(stage);
791
1042
  updateThumbs();
792
1043
  }
793
1044
 
@@ -898,6 +1149,12 @@ export async function buildDeck(deckDir, opts = {}) {
898
1149
  );
899
1150
  await lintSlidesOrThrow({ deckDir, slideFiles, layoutSources });
900
1151
 
1152
+ // The <Chart> stand-in runtime rides in the same bundle cache (see
1153
+ // loadChartPreview) — resolved after lint, which already proved the bundle
1154
+ // is materialized.
1155
+ const chartPreview = await loadChartPreview(resolveBundleDirForDeck(deckDir));
1156
+ const connectorPreview = await loadConnectorPreview(resolveBundleDirForDeck(deckDir));
1157
+
901
1158
  copyDesignSystem(deckDir, skill);
902
1159
 
903
1160
  const layoutsBlock = layoutFiles.map(inlineLayout).join("\n");
@@ -908,11 +1165,25 @@ export async function buildDeck(deckDir, opts = {}) {
908
1165
  const svgAssets = collectSvgAssets(deckDir);
909
1166
 
910
1167
  const html = HTML_TEMPLATE.replace("__TITLE__", path.basename(deckDir))
1168
+ .replace("__BRAND_FONT_FACES__", brandFontFaces(deckDir, skill))
911
1169
  .replace("__LAYOUTS__", layoutsBlock)
912
1170
  .replace("__SLIDES__", slidesBlock)
913
1171
  .replace("__OVERRIDES__", safeInlineJson(loadOverrides(deckDir)))
914
1172
  .replace("__SVG_ASSETS__", safeInlineJson(svgAssets))
915
- .replace("__RUNTIME_APPLIER__", safeInline(fs.readFileSync(RUNTIME_APPLIER, "utf8")));
1173
+ .replace("__RUNTIME_APPLIER__", safeInline(fs.readFileSync(RUNTIME_APPLIER, "utf8")))
1174
+ // Function replacers: script sources may contain `$&`-style patterns that a
1175
+ // string replacement would expand.
1176
+ .replace("__CHART_THEME_TOKENS__", () => (chartPreview ? chartPreview.tokensCss : ""))
1177
+ .replace("__CHART_PREVIEW_SCRIPTS__", () =>
1178
+ chartPreview
1179
+ ? chartPreview.scripts
1180
+ .map((script) => ` <script>\n${safeInline(script.source)}\n </script>`)
1181
+ .join("\n")
1182
+ : "",
1183
+ )
1184
+ .replace("__CONNECTOR_PREVIEW_SCRIPT__", () =>
1185
+ connectorPreview ? ` <script>\n${safeInline(connectorPreview.source)}\n </script>` : "",
1186
+ );
916
1187
 
917
1188
  const out = path.join(deckDir, "presentation.html");
918
1189
  fs.writeFileSync(out, html);
@@ -965,6 +1236,35 @@ const OPENER_EXIT_GRACE_MS = 2000;
965
1236
  // user to open manually unless `quiet` — callers with their own messaging (the
966
1237
  // review/plan gates) pass quiet:true and speak for themselves.
967
1238
  export function openInBrowser(target, { quiet = false } = {}) {
1239
+ // Machine-level browser kill-switch for headless simulation batches
1240
+ // (scripts/codex-sim.mjs writes it for the batch, removes it after): every
1241
+ // env/PATH/cwd channel for reaching a sim session proved lossy at least
1242
+ // once, so the LAST line of defense is a HOME-scoped marker no shell can
1243
+ // strip. Stale-guarded to 6h so a crashed batch can't silently disable the
1244
+ // browser for real use forever.
1245
+ // Two locations: the config dir, and THIS MODULE'S OWN DIRECTORY — session
1246
+ // shells have shown unreliable HOME/XDG resolution, but a session that runs
1247
+ // this code by definition resolves __dirname correctly.
1248
+ for (const kill of [
1249
+ path.join(
1250
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
1251
+ "slidemind",
1252
+ "suppress-browser",
1253
+ ),
1254
+ path.join(__dirname, ".sim-suppress-browser"),
1255
+ ]) {
1256
+ try {
1257
+ const age = Date.now() - fs.statSync(kill).mtimeMs;
1258
+ if (age < 6 * 60 * 60 * 1000) {
1259
+ if (!quiet) {
1260
+ process.stderr.write(`browser suppressed (simulation) — open ${target} manually\n`);
1261
+ }
1262
+ return Promise.resolve(false);
1263
+ }
1264
+ } catch {
1265
+ // marker absent — keep checking / open normally
1266
+ }
1267
+ }
968
1268
  const chain = openerChain(target);
969
1269
  return new Promise((resolve) => {
970
1270
  const tryAt = (i) => {
@@ -1012,10 +1312,15 @@ function parseArgs(argv) {
1012
1312
  args.deck = argv[++i];
1013
1313
  } else if (a === "--open") {
1014
1314
  args.open = true;
1315
+ } else if (a === "--fix") {
1316
+ args.fix = true;
1015
1317
  } else if (a === "-h" || a === "--help") {
1016
1318
  process.stdout.write(
1017
- "Usage: slidemind build <deck> [--open]\n" +
1018
- " node src/builders/build.mjs --deck <deck> [--open]\n",
1319
+ "Usage: slidemind build <deck> [--open] [--fix]\n" +
1320
+ " node src/builders/build.mjs --deck <deck> [--open] [--fix]\n" +
1321
+ " --fix apply the deterministic lint autofixes to slides/*.jsx before building\n" +
1322
+ " (pin inline-svg px size from its viewBox; letter-spacing em→px where the\n" +
1323
+ " block's px font-size makes the conversion exact)\n",
1019
1324
  );
1020
1325
  process.exit(0);
1021
1326
  } else {
@@ -1033,6 +1338,15 @@ function parseArgs(argv) {
1033
1338
  async function main() {
1034
1339
  const args = parseArgs(process.argv.slice(2));
1035
1340
  const deckDir = path.resolve(args.deck);
1341
+ if (args.fix) {
1342
+ const { fixDeckSlides } = await import("./lint-fix.mjs");
1343
+ const fixed = fixDeckSlides(deckDir);
1344
+ if (fixed.fixes) {
1345
+ process.stdout.write(
1346
+ `Autofixed ${fixed.fixes} lint issue(s) in ${fixed.files} slide file(s) — re-read any file before patching it.\n`,
1347
+ );
1348
+ }
1349
+ }
1036
1350
  const { out, slideCount } = await buildDeck(deckDir);
1037
1351
  const rel = path.relative(process.cwd(), out) || out;
1038
1352
  process.stdout.write(`Built ${rel} (${slideCount} slides)\n`);