slidemind 0.3.5 → 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.
@@ -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`);
@@ -22,8 +22,9 @@ const GATE_SESSION_PATH = path.join(__dirname, "gate-session.js");
22
22
  // overlays as CFG.pollMs / CFG.pollTtlMs:
23
23
  // GRACE_MS > POLL_MS so a waiting tab always lands one poll inside the
24
24
  // open-browser grace window;
25
- // POLL_TTL_MS > the gates' 30-min decision timeout so an abandoned tab stops
26
- // polling only after the server side has certainly given up.
25
+ // POLL_TTL_MS caps how long an abandoned tab keeps polling for a NEXT round
26
+ // (the gates themselves have no decision timeout — a live gate
27
+ // serves its own tab directly; polling only matters between runs).
27
28
  // One favicon for every page slidemind opens in a browser (gates, previews,
28
29
  // sign-in): an inline data URI, so no server needs a /favicon.ico route and
29
30
  // the static preview file stays self-contained.
@@ -87,7 +88,14 @@ export function serveStaticFile({ roots, pathname, res }) {
87
88
  continue; // missing here — try the next root
88
89
  }
89
90
  const ext = path.extname(resolved).toLowerCase();
90
- res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
91
+ // Gate tabs deliberately reuse a stable localhost URL across review rounds
92
+ // while build.mjs overwrites CSS, fonts and assets in place. Without an
93
+ // explicit no-cache policy the browser can combine fresh HTML with stale
94
+ // design resources, making the review diverge from the generated PPTX.
95
+ res.writeHead(200, {
96
+ "Content-Type": MIME[ext] || "application/octet-stream",
97
+ "Cache-Control": "no-store, max-age=0",
98
+ });
91
99
  res.end(data);
92
100
  return true;
93
101
  }
@@ -96,8 +104,8 @@ export function serveStaticFile({ roots, pathname, res }) {
96
104
 
97
105
  // Read a JSON POST body off a gate request and hand the parsed object to `cb`
98
106
  // (an unparseable/absent body becomes `{}` — a gate normalizes its own fields
99
- // anyway). Shared by every gate POST route (plan decision, plan backlog, and
100
- // the preview gate's decision + backlog next), so the 5 MB flood guard lives in
107
+ // anyway). Shared by every gate POST route (both gates' decision POSTs), so
108
+ // the 5 MB flood guard lives in
101
109
  // exactly one place. The guard matters: these servers bind loopback but any
102
110
  // page in the user's browser can POST to a known localhost port.
103
111
  export function readJsonBody(req, cb) {
@@ -130,13 +138,13 @@ export function readJsonBody(req, cb) {
130
138
  // ---------------------------------------------------------------------------
131
139
  // shared gate ROUTES + comment normalization
132
140
  //
133
- // Both gates ran byte-identical copies of the backlog route, the approve-409
134
- // guard and the row-normalization preamble. One copy, two per-gate field shapes.
141
+ // Both gates ran byte-identical copies of the approve-409 guard and the
142
+ // row-normalization preamble. One copy, two per-gate field shapes.
135
143
  // ---------------------------------------------------------------------------
136
144
 
137
145
  // The identity + text core every gate row shares: `id` dedupes a client retry
138
146
  // of the same batch, `round` says which round the comment was written against
139
- // (a queued row is delivered a round later, so the two can differ), and a row
147
+ // (rounds print it so the agent knows which render a pin sits on), and a row
140
148
  // without text is dropped — an empty row prints as an instruction-free bullet.
141
149
  // `fields(c)` contributes the gate's own anchor columns.
142
150
  export function normalizeGateComments(raw, fields) {
@@ -161,37 +169,18 @@ export function normalizeGateComments(raw, fields) {
161
169
  return out;
162
170
  }
163
171
 
164
- // The reloaded tab flushes its queued rows here. Non-empty ⇒ that run never
165
- // parks: it resolves at once as a drain round. (The page only POSTs when it HAS
166
- // a backlog, so the empty branch is just a defensive ack.)
167
- //
168
- // `isSuperseded` (the gate's settled check): once another tab's drain or
169
- // decision has resolved this run, a late backlog must NOT be acked "drained" —
170
- // the client would flip its rows to "sent" while gate.resolve is a no-op and
171
- // the reviewer's comments silently vanish. "superseded" keeps them queued, and
172
- // the tab's heartbeat poll carries them into the next run.
173
- export function serveBacklogPost(req, res, { normalize, onDrained, isSuperseded = () => false }) {
174
- readJsonBody(req, (payload) => {
175
- const comments = normalize(payload.comments);
176
- const state = !comments.length ? "parked" : isSuperseded() ? "superseded" : "drained";
177
- res.writeHead(200, { "Content-Type": MIME[".json"] });
178
- res.end(JSON.stringify({ ok: state !== "superseded", state }));
179
- if (state === "drained") onDrained(comments);
180
- });
181
- }
182
-
183
- // The decision POST, with the shared 409 guard: once this run has settled —
184
- // a backlog drained (the displayed round is superseded by comments the agent
185
- // has already been told to work) or another tab's decision landed — a late
172
+ // The decision POST, with the shared 409 guard: once this run has settled
173
+ // (another tab's decision landed — every waiting tab reloads into a new round,
174
+ // so two windows posting into one run is the normal multi-window case) a late
186
175
  // decision of EITHER kind is refused. Acking a late "comments" with 200 would
187
- // mark them sent while gate.resolve is a no-op; the 409 keeps them queued
188
- // client-side for the next round.
176
+ // mark them sent while gate.resolve is a no-op; the 409 lets the tab keep them
177
+ // as unsent drafts for the next round.
189
178
  export function serveDecisionPost(req, res, { normalize, isSuperseded, onDecision }) {
190
179
  readJsonBody(req, (payload) => {
191
180
  const decision = payload.decision === "comments" ? "comments" : "approve";
192
181
  if (isSuperseded()) {
193
182
  res.writeHead(409, { "Content-Type": MIME[".json"] });
194
- res.end(JSON.stringify({ ok: false, error: "backlog_pending" }));
183
+ res.end(JSON.stringify({ ok: false, error: "round_settled" }));
195
184
  return;
196
185
  }
197
186
  res.writeHead(200, { "Content-Type": MIME[".json"] });
@@ -217,7 +206,7 @@ export function stripModuleSyntax(src) {
217
206
 
218
207
  // gate-session.js as a browser IIFE exposing window.__SLIDEMIND_GATE__. Both
219
208
  // gate pages inline this BEFORE their overlay script — the overlays' whole
220
- // ledger/backlog/round machinery is that one shared factory.
209
+ // ledger/round machinery is that one shared factory.
221
210
  export function bundleGateSession() {
222
211
  return (
223
212
  "(function () {\n" +
@@ -249,8 +238,8 @@ export function createDecisionGate({
249
238
  isSettled = true;
250
239
  resolveDecision(value);
251
240
  },
252
- // Whether a decision/drain already resolved this run — the POST routes
253
- // refuse late input on it, because a resolve after the first is a no-op.
241
+ // Whether a decision already resolved this run — the POST route refuses
242
+ // late input on it, because a resolve after the first is a no-op.
254
243
  settled() {
255
244
  return isSettled;
256
245
  },
@@ -1,9 +1,17 @@
1
1
  // Shared browser-side session machinery for the CLI's two blocking gates —
2
2
  // the plan sign-off gate (plan-overlay.js) and the spatial preview gate
3
3
  // (review-overlay.js). Both overlays used to carry byte-identical copies of the
4
- // sessionStorage ledger, the backlog flush, the round-transition detection, the
5
- // round-ready announcement and the heartbeat poll; a fix landed in one and not
6
- // the other is exactly how the two drifted apart. This module is the ONE copy.
4
+ // sessionStorage ledger, the round-transition detection, the round-ready
5
+ // announcement and the heartbeat poll; a fix landed in one and not the other is
6
+ // exactly how the two drifted apart. This module is the ONE copy.
7
+ //
8
+ // ROUNDS ARE SYNCHRONOUS (add-in parity). A round is: the reviewer comments,
9
+ // sends, the agent works, the next run presents a new round. Nothing is queued
10
+ // behind the agent's back: while the agent works the gate is not accepting
11
+ // input, and the tab says so. (An earlier design let the tab keep collecting
12
+ // comments in sessionStorage and flushed them to the next run as a "drain
13
+ // round" — that made the add-in and the CLI behave differently and was
14
+ // removed.)
7
15
  //
8
16
  // It is plain dependency-free ESM so a test can `import` it directly, and the
9
17
  // gates inline it into their page (import-free, `export ` stripped) as
@@ -24,7 +32,7 @@
24
32
 
25
33
  // The wire shape each gate's server persists and prints. Per-gate because the
26
34
  // anchors differ (spatial pin vs. slide-anchored comment), shared here because
27
- // flushBacklog posts it and the gate's normalizer has to accept it.
35
+ // the gate's normalizer has to accept exactly this.
28
36
  const WIRE = {
29
37
  // Preview pins carry FULL-PRECISION 0..1 fractions. Rounding to whole percent
30
38
  // on the wire cost ±6px on a 1280px stage and collapsed sub-1% offsets; the
@@ -66,9 +74,9 @@ export function createGateSession(cfg) {
66
74
  const store = cfg.storage;
67
75
  const doFetch = cfg.fetch;
68
76
  const wire = cfg.wire || WIRE[name];
69
- // Deck-scoped, NOT round-scoped: the rows carry their own round, and keying
70
- // the store by the round would strand the queue on exactly the reload that
71
- // has to flush it (a new round's key differs by definition).
77
+ // Deck-scoped, NOT round-scoped: the rows carry their own round, and the
78
+ // ledger has to survive the reload INTO the next round (a new round's key
79
+ // would differ by definition).
72
80
  const LEDGER_KEY = "slidemind-" + name + "-ledger:" + deck;
73
81
  const CTX_KEY = "slidemind-" + name + "-ctx:" + deck;
74
82
  const ROUND_READY_FLAG = "slidemind-" + name + "-round-ready";
@@ -113,19 +121,18 @@ export function createGateSession(cfg) {
113
121
  }, DRAFT_DEBOUNCE_MS);
114
122
  }
115
123
 
116
- // The backlog: written in this tab, not yet delivered to any gate run.
117
- function queued(ledger) {
118
- return (ledger || []).filter(function (c) {
119
- return c.status === "queued";
120
- });
121
- }
122
-
123
124
  // Restore this tab's session and work out whether THIS load is a new round.
124
125
  // Returns the ledger plus the round state; the caller applies its own ctx
125
126
  // fields from the returned `ctx` and its own round-arrival cosmetics.
126
127
  function restore(seed) {
127
128
  let ledger = read(LEDGER_KEY, []) || [];
128
129
  if (!Array.isArray(ledger)) ledger = [];
130
+ // A tab left open across the upgrade from the queueing release still holds
131
+ // "queued" rows: undelivered, so they are the reviewer's drafts — never
132
+ // silently relabelled as sent.
133
+ ledger.forEach(function (c) {
134
+ if (c.status === "queued") c.status = "draft";
135
+ });
129
136
  let ctx = read(CTX_KEY, null);
130
137
  if (!ctx || typeof ctx !== "object") ctx = null;
131
138
  let roundNo = ctx && typeof ctx.round === "number" ? ctx.round : 1;
@@ -154,41 +161,16 @@ export function createGateSession(cfg) {
154
161
  row.status = s.status === "addressed" ? "addressed" : "sent";
155
162
  ledger.push(row);
156
163
  });
157
- // Queued rows mean the agent is still working on the round they were
158
- // written against — this load is a drain round, not a park.
159
- return { ledger, roundNo, working: queued(ledger).length > 0, newRound, ctx };
160
- }
161
-
162
- // POST the queued backlog to the gate. A non-empty backlog resolves that run
163
- // at once (drain round), so the rows flip to "sent". A failure (the command
164
- // already exited) leaves them queued for the next run — nothing is dropped.
165
- function flushBacklog(ledger) {
166
- const pending = queued(ledger);
167
- if (!pending.length) return Promise.resolve(false);
168
- return doFetch(cfg.backlogEndpoint, {
169
- method: "POST",
170
- headers: { "Content-Type": "application/json" },
171
- body: JSON.stringify({ comments: pending.map(wire) }),
172
- })
173
- .then(function (r) {
174
- return r.ok ? r.json() : null;
175
- })
176
- .then(function (data) {
177
- if (!data || data.state !== "drained") return false;
178
- pending.forEach(function (c) {
179
- c.status = "sent";
180
- });
181
- saveLedger(ledger);
182
- return true;
183
- })
184
- .catch(function () {
185
- return false; // gate gone — the rows stay queued for the next run
186
- });
164
+ // WORKING survives a plain refresh of the same run (the reviewer already
165
+ // sent this round; approving the same render again would only 409), and
166
+ // ends the moment a newer run serves the page.
167
+ const working = !newRound && !!(ctx && ctx.working);
168
+ return { ledger, roundNo, working, newRound, ctx };
187
169
  }
188
170
 
189
171
  // Where a row stands, for the ledger rail / sidebar chip.
190
172
  function statusLabel(c) {
191
- if (c.status === "queued") return "queued";
173
+ if (c.status === "draft") return "not sent yet";
192
174
  if (c.status === "addressed") {
193
175
  return "addressed" + (c.addressedInRound ? " · round " + c.addressedInRound : "");
194
176
  }
@@ -225,7 +207,7 @@ export function createGateSession(cfg) {
225
207
  }
226
208
  location.reload();
227
209
  }
228
- // Always consumed at boot (a drain round must not leave the flag armed for a
210
+ // Always consumed at boot (a refresh must not leave the flag armed for a
229
211
  // later parked load), but only ACTED on when this round parks.
230
212
  function consumeRoundFlag() {
231
213
  try {
@@ -341,9 +323,7 @@ export function createGateSession(cfg) {
341
323
  saveLedger,
342
324
  saveCtx,
343
325
  saveCtxSoon,
344
- queued,
345
326
  restore,
346
- flushBacklog,
347
327
  statusLabel,
348
328
  notifyPermission,
349
329
  requestNotifyPermission,
@@ -0,0 +1,88 @@
1
+ // Deterministic autofixes for the two lint warnings agents statistically never
2
+ // clear by hand (measured across seven sim campaigns: 7-66 unique warnings
3
+ // left standing at the final build in 8/10 sessions, resistant to four
4
+ // instruction shapes — see experiments/LEDGER.md). Both rewrites are safe
5
+ // because the fixed value is derived from the file itself, never guessed:
6
+ //
7
+ // 1. An inline `<svg>` with a `viewBox` but no explicit `width`/`height`
8
+ // gets both, taken verbatim from the viewBox — the aspect the author
9
+ // drew is preserved exactly, and the render's aspect check stops
10
+ // drifting with the flex cell.
11
+ // 2. `letter-spacing: <n>em` becomes px ONLY when the same declaration
12
+ // block names a px `font-size` to multiply against; without that context
13
+ // the correct px value is unknowable, so the declaration is left for the
14
+ // lint warning to report.
15
+ //
16
+ // Everything else the lint engine flags (raw colors, token choices, spacing
17
+ // scales) is a judgment call and deliberately NOT auto-fixed.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+
22
+ // Declaration blocks inside a <style> string or a style attribute: convert
23
+ // `letter-spacing: Nem` when the SAME block carries `font-size: Mpx`.
24
+ const DECL_BLOCK = /\{[^{}]*\}/g;
25
+ const LS_EM = /letter-spacing\s*:\s*(-?[\d.]+)em/g;
26
+ const FS_PX = /font-size\s*:\s*([\d.]+)px/;
27
+
28
+ function fixLetterSpacingBlock(block) {
29
+ const fs_ = block.match(FS_PX);
30
+ if (!fs_) return null;
31
+ const base = Number(fs_[1]);
32
+ if (!Number.isFinite(base) || base <= 0) return null;
33
+ let changed = false;
34
+ const fixed = block.replace(LS_EM, (m, em) => {
35
+ const px = Math.round(Number(em) * base * 100) / 100;
36
+ if (!Number.isFinite(px)) return m;
37
+ changed = true;
38
+ return `letter-spacing: ${px}px`;
39
+ });
40
+ return changed ? fixed : null;
41
+ }
42
+
43
+ /** Fix one slide source. Returns { src, fixes } — fixes is a list of labels. */
44
+ export function fixSlideSource(src) {
45
+ const fixes = [];
46
+ // NOTE: svg pinning (adding width/height from the viewBox) was measured to
47
+ // HARM fidelity and is disabled: the viewBox dimensions are drawing-space
48
+ // coordinates, not the layout size the author's flex cell implied, so the
49
+ // pinned svg resizes out of its cell (collisions/clipping) — the lint
50
+ // warning was satisfied by breaking the layout (sim batch 2026-08-28T06-34-11:
51
+ // fidelity 0.809 → 0.660). The correct px size is not statically knowable;
52
+ // that warning stays a judgment call for the author.
53
+ let out = src;
54
+ out = out.replace(DECL_BLOCK, (block) => {
55
+ const fixed = fixLetterSpacingBlock(block);
56
+ if (!fixed) return block;
57
+ fixes.push("letter-spacing-px");
58
+ return fixed;
59
+ });
60
+ return { src: out, fixes };
61
+ }
62
+
63
+ /**
64
+ * Fix every slides/*.jsx in a deck, writing only changed files.
65
+ * Returns { files: <changed file count>, fixes: <total fix count> }.
66
+ */
67
+ export function fixDeckSlides(deckDir) {
68
+ const slidesDir = path.join(deckDir, "slides");
69
+ let files = 0;
70
+ let fixes = 0;
71
+ let entries = [];
72
+ try {
73
+ entries = fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx"));
74
+ } catch {
75
+ return { files, fixes };
76
+ }
77
+ for (const f of entries) {
78
+ const p = path.join(slidesDir, f);
79
+ const src = fs.readFileSync(p, "utf8");
80
+ const res = fixSlideSource(src);
81
+ if (res.fixes.length) {
82
+ fs.writeFileSync(p, res.src);
83
+ files += 1;
84
+ fixes += res.fixes.length;
85
+ }
86
+ }
87
+ return { files, fixes };
88
+ }