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.
@@ -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
+ }