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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +5 -5
- package/bin/slidemind.mjs +520 -182
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +263 -104
- package/skills/search/SKILL.md +9 -5
- package/src/bridge/build-and-push.mjs +3 -3
- package/src/bridge/bundle.mjs +91 -36
- package/src/bridge/capture.mjs +34 -0
- package/src/bridge/client.mjs +59 -23
- package/src/bridge/config.mjs +21 -2
- package/src/bridge/engine-download.mjs +26 -7
- package/src/bridge/recolor.mjs +46 -0
- package/src/bridge/setup-claim.mjs +7 -1
- package/src/builders/build.mjs +179 -5
- package/src/builders/gate-server.mjs +25 -36
- package/src/builders/gate-session.js +28 -48
- package/src/builders/lint-fix.mjs +88 -0
- package/src/builders/plan-overlay.js +507 -136
- package/src/builders/plan.mjs +337 -60
- package/src/builders/review-overlay.js +126 -109
- package/src/builders/review.mjs +118 -64
- package/src/builders/storyboard-markdown.mjs +15 -0
- package/src/builders/storyboard-schema.mjs +23 -6
- package/src/builders/visual-families.mjs +20 -4
- package/src/lib/adopt.mjs +10 -6
- package/src/lib/conversions.mjs +26 -6
- package/src/lib/overwrite-guard.mjs +14 -1
- package/src/lib/reconcile.mjs +15 -4
- package/src/pptx/tags.mjs +49 -0
package/src/builders/build.mjs
CHANGED
|
@@ -29,9 +29,12 @@
|
|
|
29
29
|
|
|
30
30
|
import { spawn } from "node:child_process";
|
|
31
31
|
import fs from "node:fs";
|
|
32
|
+
import os from "node:os";
|
|
32
33
|
import path from "node:path";
|
|
33
34
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
34
35
|
|
|
36
|
+
import { resolveBundleDirForDeck } from "../bridge/bundle.mjs";
|
|
37
|
+
import { loadChartPreview } from "../bridge/capture.mjs";
|
|
35
38
|
import { FAVICON_LINK } from "./gate-server.mjs";
|
|
36
39
|
import { lintSlidesOrThrow } from "./lint.mjs";
|
|
37
40
|
import { resolveSkillForDeck } from "./resolve-skill.mjs";
|
|
@@ -145,6 +148,98 @@ function copyDesignSystem(deckDir, skill) {
|
|
|
145
148
|
fs.writeFileSync(path.join(deckDir, "layouts.css"), cssChunks.join("\n\n"));
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
// First concrete (non-generic) family of a CSS var's font stack, or null.
|
|
152
|
+
function firstConcreteFamily(cssText, varName) {
|
|
153
|
+
const match = cssText.match(new RegExp(`--${varName}\\s*:\\s*([^;]+);`));
|
|
154
|
+
if (!match) return null;
|
|
155
|
+
const generics = new Set([
|
|
156
|
+
"sans-serif",
|
|
157
|
+
"serif",
|
|
158
|
+
"monospace",
|
|
159
|
+
"system-ui",
|
|
160
|
+
"ui-sans-serif",
|
|
161
|
+
"ui-serif",
|
|
162
|
+
"ui-monospace",
|
|
163
|
+
"cursive",
|
|
164
|
+
"fantasy",
|
|
165
|
+
]);
|
|
166
|
+
for (const part of match[1].split(",")) {
|
|
167
|
+
const family = part.trim().replace(/^['"]|['"]$/g, "");
|
|
168
|
+
if (family && !generics.has(family.toLowerCase())) return family;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Copy the materialized bundle's brand fonts next to the preview and emit the
|
|
174
|
+
// same @font-face block the server's capture page uses: one rule per FACE
|
|
175
|
+
// under its canonical family plus the CSS-token alias (--font-sans/--font-mono),
|
|
176
|
+
// with real weight/style descriptors so bold/italic render (and later measure)
|
|
177
|
+
// with the real face instead of a synthesized one. Without this the preview
|
|
178
|
+
// showed system fallbacks while capture/export used the brand face.
|
|
179
|
+
function brandFontFaces(deckDir, skill) {
|
|
180
|
+
const bundleDir = skill.bundleDir;
|
|
181
|
+
if (!bundleDir || !fs.existsSync(bundleDir)) return "";
|
|
182
|
+
let meta = {};
|
|
183
|
+
try {
|
|
184
|
+
meta = JSON.parse(
|
|
185
|
+
fs.readFileSync(path.join(bundleDir, "bundle.json"), "utf8"),
|
|
186
|
+
);
|
|
187
|
+
} catch {
|
|
188
|
+
return "";
|
|
189
|
+
}
|
|
190
|
+
const fonts = Array.isArray(meta.fonts) ? meta.fonts : [];
|
|
191
|
+
if (fonts.length === 0) return "";
|
|
192
|
+
|
|
193
|
+
let css = "";
|
|
194
|
+
try {
|
|
195
|
+
css = fs.readFileSync(path.join(deckDir, "corporate-design.css"), "utf8");
|
|
196
|
+
} catch {
|
|
197
|
+
/* alias resolution degrades to canonical families only */
|
|
198
|
+
}
|
|
199
|
+
const aliasByRole = {
|
|
200
|
+
primary: firstConcreteFamily(css, "font-sans"),
|
|
201
|
+
mono: firstConcreteFamily(css, "font-mono"),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const outFonts = path.join(deckDir, "fonts");
|
|
205
|
+
const rules = [];
|
|
206
|
+
for (const font of fonts) {
|
|
207
|
+
if (!font?.family || !font?.filename) continue;
|
|
208
|
+
const filename = path.basename(font.filename);
|
|
209
|
+
const src = path.join(bundleDir, "fonts", filename);
|
|
210
|
+
if (!fs.existsSync(src)) continue;
|
|
211
|
+
fs.mkdirSync(outFonts, { recursive: true });
|
|
212
|
+
fs.copyFileSync(src, path.join(outFonts, filename));
|
|
213
|
+
|
|
214
|
+
const weight =
|
|
215
|
+
font.variable &&
|
|
216
|
+
typeof font.variable.minWeight === "number" &&
|
|
217
|
+
typeof font.variable.maxWeight === "number"
|
|
218
|
+
? `${font.variable.minWeight} ${font.variable.maxWeight}`
|
|
219
|
+
: String(
|
|
220
|
+
typeof font.weight === "number" &&
|
|
221
|
+
font.weight >= 100 &&
|
|
222
|
+
font.weight <= 900
|
|
223
|
+
? font.weight
|
|
224
|
+
: 400,
|
|
225
|
+
);
|
|
226
|
+
const style = font.style === "italic" ? "italic" : "normal";
|
|
227
|
+
const format = filename.toLowerCase().endsWith(".otf")
|
|
228
|
+
? "opentype"
|
|
229
|
+
: "truetype";
|
|
230
|
+
const rule = (family) =>
|
|
231
|
+
`@font-face { font-family: ${JSON.stringify(family)}; ` +
|
|
232
|
+
`src: url(${JSON.stringify(`./fonts/${filename}`)}) format("${format}"); ` +
|
|
233
|
+
`font-weight: ${weight}; font-style: ${style}; font-display: block; }`;
|
|
234
|
+
rules.push(rule(font.family));
|
|
235
|
+
const alias = aliasByRole[font.role];
|
|
236
|
+
if (alias && alias.toLowerCase() !== font.family.toLowerCase()) {
|
|
237
|
+
rules.push(rule(alias));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return rules.join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
148
243
|
function loadOverrides(deckDir) {
|
|
149
244
|
const file = path.join(deckDir, "overrides.json");
|
|
150
245
|
if (!fs.existsSync(file)) return { version: 1, slides: {} };
|
|
@@ -258,6 +353,10 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
258
353
|
font-style: italic;
|
|
259
354
|
}
|
|
260
355
|
</style>
|
|
356
|
+
<style id="brand-font-faces">__BRAND_FONT_FACES__</style>
|
|
357
|
+
<!-- Chart palette for the <Chart> stand-in (from the bundle's capture engine;
|
|
358
|
+
empty when the bundle carries no chart runtime). -->
|
|
359
|
+
<style id="chart-theme-tokens">__CHART_THEME_TOKENS__</style>
|
|
261
360
|
<style>
|
|
262
361
|
html, body { margin: 0; padding: 0; height: 100%; background: #0a0a0a; overflow: hidden; }
|
|
263
362
|
:root {
|
|
@@ -470,6 +569,11 @@ __SLIDES__
|
|
|
470
569
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
471
570
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
472
571
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
572
|
+
<!-- The <Chart> stand-in runtime: the add-in's own vendored scripts, inlined
|
|
573
|
+
from the bundle's capture engine (this CLI ships no chart source). They
|
|
574
|
+
install window.SlidemindChart, which the bootstrap below binds as Chart
|
|
575
|
+
before any slide is evaluated. -->
|
|
576
|
+
__CHART_PREVIEW_SCRIPTS__
|
|
473
577
|
<script>window.__SLIDE_EDITOR_OVERRIDES__ = __OVERRIDES__;</script>
|
|
474
578
|
<script>window.SVG_ASSETS = __SVG_ASSETS__;</script>
|
|
475
579
|
<script>
|
|
@@ -500,8 +604,11 @@ __RUNTIME_APPLIER__
|
|
|
500
604
|
var nameNode = node.name;
|
|
501
605
|
// Intrinsic elements only: a lowercase JSXIdentifier. Skip
|
|
502
606
|
// components (<SlideCover>), member/namespaced names, fragments.
|
|
607
|
+
// <Chart> is the one component that opts in (as in the add-in's
|
|
608
|
+
// instrument-source-loc): it forwards the attribute onto its
|
|
609
|
+
// root div, so a review pin on a chart resolves to its tag.
|
|
503
610
|
if (!nameNode || nameNode.type !== "JSXIdentifier") return;
|
|
504
|
-
if (!/^[a-z]/.test(nameNode.name)) return;
|
|
611
|
+
if (!/^[a-z]/.test(nameNode.name) && nameNode.name !== "Chart") return;
|
|
505
612
|
for (var i = 0; i < node.attributes.length; i++) {
|
|
506
613
|
var a = node.attributes[i];
|
|
507
614
|
if (a.type === "JSXAttribute" && a.name &&
|
|
@@ -649,6 +756,14 @@ __RUNTIME_APPLIER__
|
|
|
649
756
|
}
|
|
650
757
|
});
|
|
651
758
|
|
|
759
|
+
// The Chart binding slide jsx is compiled against — installed by the
|
|
760
|
+
// bundle's chart-preview scripts above; without them a chart renders
|
|
761
|
+
// nothing, exactly as the add-in's page behaves.
|
|
762
|
+
var Chart = window.SlidemindChart
|
|
763
|
+
? window.SlidemindChart.createComponent(React)
|
|
764
|
+
: function () { return null; };
|
|
765
|
+
var slideScope = Object.assign({ Chart: Chart }, LAYOUTS);
|
|
766
|
+
|
|
652
767
|
var slides = [];
|
|
653
768
|
document.querySelectorAll('script[data-kind="slide"]').forEach(function (s) {
|
|
654
769
|
var idx = parseInt(s.dataset.index, 10);
|
|
@@ -657,7 +772,7 @@ __RUNTIME_APPLIER__
|
|
|
657
772
|
var Component = evalJsx(
|
|
658
773
|
s.textContent,
|
|
659
774
|
"slides/" + name + ".jsx",
|
|
660
|
-
|
|
775
|
+
slideScope
|
|
661
776
|
);
|
|
662
777
|
slides[idx] = { name: name, Component: Component };
|
|
663
778
|
} catch (err) {
|
|
@@ -898,6 +1013,11 @@ export async function buildDeck(deckDir, opts = {}) {
|
|
|
898
1013
|
);
|
|
899
1014
|
await lintSlidesOrThrow({ deckDir, slideFiles, layoutSources });
|
|
900
1015
|
|
|
1016
|
+
// The <Chart> stand-in runtime rides in the same bundle cache (see
|
|
1017
|
+
// loadChartPreview) — resolved after lint, which already proved the bundle
|
|
1018
|
+
// is materialized.
|
|
1019
|
+
const chartPreview = await loadChartPreview(resolveBundleDirForDeck(deckDir));
|
|
1020
|
+
|
|
901
1021
|
copyDesignSystem(deckDir, skill);
|
|
902
1022
|
|
|
903
1023
|
const layoutsBlock = layoutFiles.map(inlineLayout).join("\n");
|
|
@@ -908,11 +1028,22 @@ export async function buildDeck(deckDir, opts = {}) {
|
|
|
908
1028
|
const svgAssets = collectSvgAssets(deckDir);
|
|
909
1029
|
|
|
910
1030
|
const html = HTML_TEMPLATE.replace("__TITLE__", path.basename(deckDir))
|
|
1031
|
+
.replace("__BRAND_FONT_FACES__", brandFontFaces(deckDir, skill))
|
|
911
1032
|
.replace("__LAYOUTS__", layoutsBlock)
|
|
912
1033
|
.replace("__SLIDES__", slidesBlock)
|
|
913
1034
|
.replace("__OVERRIDES__", safeInlineJson(loadOverrides(deckDir)))
|
|
914
1035
|
.replace("__SVG_ASSETS__", safeInlineJson(svgAssets))
|
|
915
|
-
.replace("__RUNTIME_APPLIER__", safeInline(fs.readFileSync(RUNTIME_APPLIER, "utf8")))
|
|
1036
|
+
.replace("__RUNTIME_APPLIER__", safeInline(fs.readFileSync(RUNTIME_APPLIER, "utf8")))
|
|
1037
|
+
// Function replacers: script sources may contain `$&`-style patterns that a
|
|
1038
|
+
// string replacement would expand.
|
|
1039
|
+
.replace("__CHART_THEME_TOKENS__", () => (chartPreview ? chartPreview.tokensCss : ""))
|
|
1040
|
+
.replace("__CHART_PREVIEW_SCRIPTS__", () =>
|
|
1041
|
+
chartPreview
|
|
1042
|
+
? chartPreview.scripts
|
|
1043
|
+
.map((script) => ` <script>\n${safeInline(script.source)}\n </script>`)
|
|
1044
|
+
.join("\n")
|
|
1045
|
+
: "",
|
|
1046
|
+
);
|
|
916
1047
|
|
|
917
1048
|
const out = path.join(deckDir, "presentation.html");
|
|
918
1049
|
fs.writeFileSync(out, html);
|
|
@@ -965,6 +1096,35 @@ const OPENER_EXIT_GRACE_MS = 2000;
|
|
|
965
1096
|
// user to open manually unless `quiet` — callers with their own messaging (the
|
|
966
1097
|
// review/plan gates) pass quiet:true and speak for themselves.
|
|
967
1098
|
export function openInBrowser(target, { quiet = false } = {}) {
|
|
1099
|
+
// Machine-level browser kill-switch for headless simulation batches
|
|
1100
|
+
// (scripts/codex-sim.mjs writes it for the batch, removes it after): every
|
|
1101
|
+
// env/PATH/cwd channel for reaching a sim session proved lossy at least
|
|
1102
|
+
// once, so the LAST line of defense is a HOME-scoped marker no shell can
|
|
1103
|
+
// strip. Stale-guarded to 6h so a crashed batch can't silently disable the
|
|
1104
|
+
// browser for real use forever.
|
|
1105
|
+
// Two locations: the config dir, and THIS MODULE'S OWN DIRECTORY — session
|
|
1106
|
+
// shells have shown unreliable HOME/XDG resolution, but a session that runs
|
|
1107
|
+
// this code by definition resolves __dirname correctly.
|
|
1108
|
+
for (const kill of [
|
|
1109
|
+
path.join(
|
|
1110
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
|
1111
|
+
"slidemind",
|
|
1112
|
+
"suppress-browser",
|
|
1113
|
+
),
|
|
1114
|
+
path.join(__dirname, ".sim-suppress-browser"),
|
|
1115
|
+
]) {
|
|
1116
|
+
try {
|
|
1117
|
+
const age = Date.now() - fs.statSync(kill).mtimeMs;
|
|
1118
|
+
if (age < 6 * 60 * 60 * 1000) {
|
|
1119
|
+
if (!quiet) {
|
|
1120
|
+
process.stderr.write(`browser suppressed (simulation) — open ${target} manually\n`);
|
|
1121
|
+
}
|
|
1122
|
+
return Promise.resolve(false);
|
|
1123
|
+
}
|
|
1124
|
+
} catch {
|
|
1125
|
+
// marker absent — keep checking / open normally
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
968
1128
|
const chain = openerChain(target);
|
|
969
1129
|
return new Promise((resolve) => {
|
|
970
1130
|
const tryAt = (i) => {
|
|
@@ -1012,10 +1172,15 @@ function parseArgs(argv) {
|
|
|
1012
1172
|
args.deck = argv[++i];
|
|
1013
1173
|
} else if (a === "--open") {
|
|
1014
1174
|
args.open = true;
|
|
1175
|
+
} else if (a === "--fix") {
|
|
1176
|
+
args.fix = true;
|
|
1015
1177
|
} else if (a === "-h" || a === "--help") {
|
|
1016
1178
|
process.stdout.write(
|
|
1017
|
-
"Usage: slidemind build <deck> [--open]\n" +
|
|
1018
|
-
" node src/builders/build.mjs --deck <deck> [--open]\n"
|
|
1179
|
+
"Usage: slidemind build <deck> [--open] [--fix]\n" +
|
|
1180
|
+
" node src/builders/build.mjs --deck <deck> [--open] [--fix]\n" +
|
|
1181
|
+
" --fix apply the deterministic lint autofixes to slides/*.jsx before building\n" +
|
|
1182
|
+
" (pin inline-svg px size from its viewBox; letter-spacing em→px where the\n" +
|
|
1183
|
+
" block's px font-size makes the conversion exact)\n",
|
|
1019
1184
|
);
|
|
1020
1185
|
process.exit(0);
|
|
1021
1186
|
} else {
|
|
@@ -1033,6 +1198,15 @@ function parseArgs(argv) {
|
|
|
1033
1198
|
async function main() {
|
|
1034
1199
|
const args = parseArgs(process.argv.slice(2));
|
|
1035
1200
|
const deckDir = path.resolve(args.deck);
|
|
1201
|
+
if (args.fix) {
|
|
1202
|
+
const { fixDeckSlides } = await import("./lint-fix.mjs");
|
|
1203
|
+
const fixed = fixDeckSlides(deckDir);
|
|
1204
|
+
if (fixed.fixes) {
|
|
1205
|
+
process.stdout.write(
|
|
1206
|
+
`Autofixed ${fixed.fixes} lint issue(s) in ${fixed.files} slide file(s) — re-read any file before patching it.\n`,
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1036
1210
|
const { out, slideCount } = await buildDeck(deckDir);
|
|
1037
1211
|
const rel = path.relative(process.cwd(), out) || out;
|
|
1038
1212
|
process.stdout.write(`Built ${rel} (${slideCount} slides)\n`);
|
|
@@ -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
|
|
26
|
-
//
|
|
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
|
-
|
|
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 (
|
|
100
|
-
// the
|
|
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
|
|
134
|
-
//
|
|
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
|
-
// (
|
|
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
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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
|
|
188
|
-
//
|
|
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: "
|
|
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/
|
|
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
|
|
253
|
-
//
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
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
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
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 === "
|
|
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
|
|
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
|
+
}
|