slidemind 0.1.8 → 0.1.9
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.md +7 -6
- package/README.md +19 -7
- package/bin/slidemind.mjs +377 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +66 -15
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -0
- package/src/builders/build.mjs +53 -23
- package/src/builders/gate-server.mjs +163 -22
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-overlay.js +1261 -230
- package/src/builders/plan.mjs +146 -25
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/review-overlay.js +44 -7
- package/src/builders/review.mjs +72 -10
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
package/src/builders/plan.mjs
CHANGED
|
@@ -55,7 +55,9 @@ import {
|
|
|
55
55
|
listenWithFallback,
|
|
56
56
|
preferredGatePort,
|
|
57
57
|
serveAndOpen,
|
|
58
|
+
pendingBrowserLines,
|
|
58
59
|
GRACE_MS,
|
|
60
|
+
OPEN_FAILED_GRACE_MS,
|
|
59
61
|
POLL_MS,
|
|
60
62
|
POLL_TTL_MS,
|
|
61
63
|
} from "./gate-server.mjs";
|
|
@@ -65,6 +67,14 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
65
67
|
const __dirname = path.dirname(__filename);
|
|
66
68
|
const OVERLAY_PATH = path.join(__dirname, "plan-overlay.js");
|
|
67
69
|
const PLAN_MD_PATH = path.join(__dirname, "plan-md.mjs");
|
|
70
|
+
// The storyboard/red-thread plan-review modules, bundled into the browser page
|
|
71
|
+
// alongside plan-md.mjs (see bundlePlanModules). Order matters: each file's
|
|
72
|
+
// inter-module imports are stripped, so a module must be concatenated AFTER the
|
|
73
|
+
// module whose exports it references (schema ← storyboard-markdown ← backbone).
|
|
74
|
+
const STORYBOARD_SCHEMA_PATH = path.join(__dirname, "storyboard-schema.mjs");
|
|
75
|
+
const STORYBOARD_MARKDOWN_PATH = path.join(__dirname, "storyboard-markdown.mjs");
|
|
76
|
+
const RED_THREAD_BACKBONE_PATH = path.join(__dirname, "red-thread-backbone.mjs");
|
|
77
|
+
const PLAN_MARKDOWN_EDIT_PATH = path.join(__dirname, "plan-markdown-edit.mjs");
|
|
68
78
|
const PLAN_STATE_FILE = "plan-review.json";
|
|
69
79
|
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
70
80
|
|
|
@@ -149,6 +159,14 @@ export function formatPlanFeedbackForHarness(deckDir, comment, { edited = false
|
|
|
149
159
|
out.push("(No written feedback — their direct edits to plan.md are the change.)");
|
|
150
160
|
out.push("");
|
|
151
161
|
}
|
|
162
|
+
// Recovery pointer in the tail (truncated captures usually lose the TOP):
|
|
163
|
+
// the round is persisted before this prints, so a lost capture is recoverable.
|
|
164
|
+
out.push(
|
|
165
|
+
`This round (decision + feedback) is also saved verbatim in ` +
|
|
166
|
+
`${path.join(deckDir, PLAN_STATE_FILE)} — if this output looks truncated, read ` +
|
|
167
|
+
`that file instead of guessing.`,
|
|
168
|
+
);
|
|
169
|
+
out.push("");
|
|
152
170
|
out.push(
|
|
153
171
|
"The user's browser tab is still open, on a waiting spinner that says you are",
|
|
154
172
|
);
|
|
@@ -165,12 +183,13 @@ export function formatPlanFeedbackForHarness(deckDir, comment, { edited = false
|
|
|
165
183
|
// Render the no-answer outcome for the harness. A timed-out gate is an EXPECTED
|
|
166
184
|
// state (the user stepped away), not a failure — the agent must neither treat
|
|
167
185
|
// it as approval nor abort the task; it wraps up with clear resume instructions.
|
|
168
|
-
export function formatPlanPendingForHarness(deckDir, { timeoutMs } = {}) {
|
|
186
|
+
export function formatPlanPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
|
|
169
187
|
const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
|
|
170
188
|
const base = path.basename(deckDir);
|
|
171
189
|
return [
|
|
172
190
|
"DECISION: pending",
|
|
173
191
|
`No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
|
|
192
|
+
...pendingBrowserLines({ pageLoaded, url }),
|
|
174
193
|
"This is NOT an approval and NOT a rejection. Do not author anything.",
|
|
175
194
|
"Close out your turn gracefully:",
|
|
176
195
|
" 1. Tell the user their plan is still waiting for sign-off in the browser.",
|
|
@@ -181,6 +200,82 @@ export function formatPlanPendingForHarness(deckDir, { timeoutMs } = {}) {
|
|
|
181
200
|
].join("\n") + "\n";
|
|
182
201
|
}
|
|
183
202
|
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// browser bundle
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
// The exact set of names the bundled IIFE hangs off window.__SLIDEMIND_PLAN__ —
|
|
208
|
+
// this is the contract the overlay (plan-overlay.js) reads. Every name here must
|
|
209
|
+
// resolve to a top-level binding in one of the five concatenated modules.
|
|
210
|
+
const PLAN_NAMESPACE_EXPORTS = [
|
|
211
|
+
"renderPlanMarkdown",
|
|
212
|
+
"addEditableAttributes",
|
|
213
|
+
"isEditableKind",
|
|
214
|
+
"replaceRenderedMarkdownBlock",
|
|
215
|
+
"splitPlanMarkdownIntoSegments",
|
|
216
|
+
"replaceStoryboardSegment",
|
|
217
|
+
"extractRedThreadMessages",
|
|
218
|
+
"findRedThreadSection",
|
|
219
|
+
"parseStoryboardCanvas",
|
|
220
|
+
"serializeStoryboardCanvas",
|
|
221
|
+
"backboneFromMessages",
|
|
222
|
+
"parsePlanBackbone",
|
|
223
|
+
"applyPlanBackbone",
|
|
224
|
+
"stageStoryIteration",
|
|
225
|
+
"STORYBOARD_WIDTH",
|
|
226
|
+
"STORYBOARD_HEIGHT",
|
|
227
|
+
"BODY_SAFE_AREA",
|
|
228
|
+
"MIN_BLOCK_WIDTH",
|
|
229
|
+
"MIN_BLOCK_HEIGHT",
|
|
230
|
+
"MAX_BLOCKS",
|
|
231
|
+
"MAX_DESIGN_ELEMENT_LENGTH",
|
|
232
|
+
"MAX_CONTENT_LENGTH",
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
// Turn a dependency-free ESM module's source into plain browser JS that runs
|
|
236
|
+
// inside a shared IIFE scope: drop single-line `import … from "…";` statements
|
|
237
|
+
// (all inter-module imports across the plan-review modules are single-line) and
|
|
238
|
+
// strip the leading `export ` keyword from declarations. The imported names are
|
|
239
|
+
// provided by an earlier module in the concatenation, so removing the import
|
|
240
|
+
// line just leaves the reference resolving to that earlier top-level binding.
|
|
241
|
+
export function stripModuleSyntax(src) {
|
|
242
|
+
return String(src || "")
|
|
243
|
+
.replace(/^\s*import\s+[^;]*?from\s*["'][^"']+["'];?\s*$/gm, "")
|
|
244
|
+
.replace(/^export\s+/gm, "");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Bundle the five plan-review modules into one IIFE for the browser page, in
|
|
248
|
+
// dependency order (schema → storyboard-markdown → backbone → edit → plan-md).
|
|
249
|
+
// The IIFE exposes window.__SLIDEMIND_RENDER_MD__ (back-compat with the old
|
|
250
|
+
// preview) and window.__SLIDEMIND_PLAN__ (the full namespace the overlay reads).
|
|
251
|
+
// Exported so a test can evaluate the output against a stub window and catch a
|
|
252
|
+
// strip-regex miss or a top-level name collision for real.
|
|
253
|
+
export function bundlePlanModules({ schema, storyboardMd, backbone, edit, planMd } = {}) {
|
|
254
|
+
const body = [schema, storyboardMd, backbone, edit, planMd]
|
|
255
|
+
.map(stripModuleSyntax)
|
|
256
|
+
.join("\n");
|
|
257
|
+
return (
|
|
258
|
+
"(function () {\n" +
|
|
259
|
+
body +
|
|
260
|
+
"\nwindow.__SLIDEMIND_RENDER_MD__ = renderPlanMarkdown;\n" +
|
|
261
|
+
"window.__SLIDEMIND_PLAN__ = { " +
|
|
262
|
+
PLAN_NAMESPACE_EXPORTS.join(", ") +
|
|
263
|
+
" };\n})();\n"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Read the five module sources off disk and bundle them. Split from
|
|
268
|
+
// bundlePlanModules so the pure string→string step stays unit-testable.
|
|
269
|
+
export function bundlePlanModulesFromDisk() {
|
|
270
|
+
return bundlePlanModules({
|
|
271
|
+
schema: fs.readFileSync(STORYBOARD_SCHEMA_PATH, "utf8"),
|
|
272
|
+
storyboardMd: fs.readFileSync(STORYBOARD_MARKDOWN_PATH, "utf8"),
|
|
273
|
+
backbone: fs.readFileSync(RED_THREAD_BACKBONE_PATH, "utf8"),
|
|
274
|
+
edit: fs.readFileSync(PLAN_MARKDOWN_EDIT_PATH, "utf8"),
|
|
275
|
+
planMd: fs.readFileSync(PLAN_MD_PATH, "utf8"),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
184
279
|
// ---------------------------------------------------------------------------
|
|
185
280
|
// page
|
|
186
281
|
// ---------------------------------------------------------------------------
|
|
@@ -224,17 +319,16 @@ display:block;overflow-x:auto;}
|
|
|
224
319
|
|
|
225
320
|
// Build the standalone plan-review page: the rendered markdown in #plan-root
|
|
226
321
|
// (the no-JS fallback and the preview's initial state), the injected overlay
|
|
227
|
-
// config, the
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// unit-testable without a server.
|
|
322
|
+
// config, the bundled plan-review modules, then the overlay script itself.
|
|
323
|
+
// `planModulesJs` is the prebundled IIFE from bundlePlanModules — it already
|
|
324
|
+
// exposes window.__SLIDEMIND_RENDER_MD__ and window.__SLIDEMIND_PLAN__, so this
|
|
325
|
+
// only inlines it verbatim (with the </script> break-out guard). Kept a pure
|
|
326
|
+
// function of its inputs so it's unit-testable without a server.
|
|
233
327
|
export function buildPlanPage({
|
|
234
328
|
bodyHtml,
|
|
235
329
|
previousComment,
|
|
236
330
|
overlayJs,
|
|
237
|
-
|
|
331
|
+
planModulesJs,
|
|
238
332
|
logoMark,
|
|
239
333
|
planBody,
|
|
240
334
|
bodyStartLine,
|
|
@@ -252,14 +346,8 @@ export function buildPlanPage({
|
|
|
252
346
|
bodyStartLine: bodyStartLine ?? 1,
|
|
253
347
|
});
|
|
254
348
|
const overlay = String(overlayJs || "").replace(/<\/script>/g, "<\\/script>");
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
.replace(/<\/script>/g, "<\\/script>");
|
|
258
|
-
const rendererBlock = rendererSrc
|
|
259
|
-
? "<script>\n(function () {\n" +
|
|
260
|
-
rendererSrc +
|
|
261
|
-
"\nwindow.__SLIDEMIND_RENDER_MD__ = renderPlanMarkdown;\n})();\n</script>\n"
|
|
262
|
-
: "";
|
|
349
|
+
const modulesSrc = String(planModulesJs || "").replace(/<\/script>/g, "<\\/script>");
|
|
350
|
+
const rendererBlock = modulesSrc ? "<script>\n" + modulesSrc + "\n</script>\n" : "";
|
|
263
351
|
return (
|
|
264
352
|
"<!doctype html>\n" +
|
|
265
353
|
'<html lang="en">\n' +
|
|
@@ -293,7 +381,7 @@ export async function createPlanReviewServer({
|
|
|
293
381
|
bodyStartLine,
|
|
294
382
|
previousComment = null,
|
|
295
383
|
overlayJs,
|
|
296
|
-
|
|
384
|
+
planModulesJs,
|
|
297
385
|
logoMark = null,
|
|
298
386
|
brandAssetPath = null,
|
|
299
387
|
port = 0,
|
|
@@ -305,13 +393,13 @@ export async function createPlanReviewServer({
|
|
|
305
393
|
// NEXT plan round — never into the preview gate or a foreign app on the port.
|
|
306
394
|
const hb = createHeartbeat({ service: "slidemind-plan" });
|
|
307
395
|
const overlay = overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
|
|
308
|
-
const
|
|
396
|
+
const planModules = planModulesJs ?? bundlePlanModulesFromDisk();
|
|
309
397
|
const bodyHtml = renderPlanMarkdown(body, { lineOffset: bodyStartLine - 1 });
|
|
310
398
|
const indexHtml = buildPlanPage({
|
|
311
399
|
bodyHtml,
|
|
312
400
|
previousComment,
|
|
313
401
|
overlayJs: overlay,
|
|
314
|
-
|
|
402
|
+
planModulesJs: planModules,
|
|
315
403
|
logoMark,
|
|
316
404
|
planBody: body,
|
|
317
405
|
bodyStartLine,
|
|
@@ -355,11 +443,20 @@ export async function createPlanReviewServer({
|
|
|
355
443
|
}
|
|
356
444
|
|
|
357
445
|
if (pathname === "/__plan/heartbeat") {
|
|
358
|
-
|
|
446
|
+
// `visible`/`round` from the poll drive the reuse-vs-fresh-tab decision:
|
|
447
|
+
// only a VISIBLE waiting tab may claim reuse (see gate-server.mjs).
|
|
448
|
+
hb.respond(res, {
|
|
449
|
+
visible: u.searchParams.get("visible") === "1",
|
|
450
|
+
tabRound: u.searchParams.get("round"),
|
|
451
|
+
});
|
|
359
452
|
return;
|
|
360
453
|
}
|
|
361
454
|
|
|
362
455
|
if (pathname === "/" || pathname === "/index.html") {
|
|
456
|
+
// A browser fetched the gate page — the end-to-end proof the plan review
|
|
457
|
+
// actually opened in front of the user (see gate-server's page-load
|
|
458
|
+
// tracker; serveAndOpen and the pending contract report from it).
|
|
459
|
+
hb.notePageLoad();
|
|
363
460
|
res.writeHead(200, { "Content-Type": MIME[".html"] });
|
|
364
461
|
res.end(indexHtml);
|
|
365
462
|
return;
|
|
@@ -401,6 +498,9 @@ export async function createPlanReviewServer({
|
|
|
401
498
|
url,
|
|
402
499
|
port: addr.port,
|
|
403
500
|
waitForHeartbeat: (ms) => hb.waitForHeartbeat(ms),
|
|
501
|
+
markOpenedFresh: () => hb.markFresh(),
|
|
502
|
+
waitForPageLoad: (ms) => hb.waitForPageLoad(ms),
|
|
503
|
+
pageLoaded: () => hb.pageLoaded(),
|
|
404
504
|
waitForDecision: (opts) => gate.waitForDecision(opts),
|
|
405
505
|
close() {
|
|
406
506
|
try {
|
|
@@ -430,7 +530,9 @@ export async function runPlanReview({
|
|
|
430
530
|
deckDir,
|
|
431
531
|
port = null,
|
|
432
532
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
433
|
-
open
|
|
533
|
+
// quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
|
|
534
|
+
open = (url) => openInBrowser(url, { quiet: true }),
|
|
535
|
+
openFailedGraceMs = OPEN_FAILED_GRACE_MS,
|
|
434
536
|
} = {}) {
|
|
435
537
|
const base = path.basename(deckDir);
|
|
436
538
|
const planPath = path.join(deckDir, "plan.md");
|
|
@@ -467,13 +569,12 @@ export async function runPlanReview({
|
|
|
467
569
|
});
|
|
468
570
|
// A waiting tab can only exist when the previous round ended in comments — so
|
|
469
571
|
// first runs / post-approve runs open the browser immediately (graceMs 0).
|
|
470
|
-
await serveAndOpen(server, {
|
|
572
|
+
const browser = await serveAndOpen(server, {
|
|
471
573
|
open,
|
|
472
574
|
graceMs: prev.decision === "comments" ? GRACE_MS : 0,
|
|
473
575
|
readyLabel: "Plan review",
|
|
474
576
|
reuseNoun: "plan tab",
|
|
475
|
-
|
|
476
|
-
"Opened your browser — edit the plan or tell the agent what to change; approving starts the build.",
|
|
577
|
+
gateNoun: "plan review",
|
|
477
578
|
waitLine:
|
|
478
579
|
`Waiting for the user's decision — this command BLOCKS (up to ${Math.round(
|
|
479
580
|
timeoutMs / 60000,
|
|
@@ -482,12 +583,32 @@ export async function runPlanReview({
|
|
|
482
583
|
|
|
483
584
|
let decision;
|
|
484
585
|
try {
|
|
586
|
+
if (browser.launched === false) {
|
|
587
|
+
// The opener chain failed outright: the user can only reach the gate via
|
|
588
|
+
// the printed URL. Give a manually-opened tab a short window, then bail
|
|
589
|
+
// out with the pending contract (carrying the failed-open status + link)
|
|
590
|
+
// instead of blocking the agent for the full timeout on an invisible gate.
|
|
591
|
+
const loaded = await server.waitForPageLoad(openFailedGraceMs);
|
|
592
|
+
if (!loaded) {
|
|
593
|
+
const err = new Error("browser never opened — gate unseen");
|
|
594
|
+
err.code = "GATE_TIMEOUT";
|
|
595
|
+
throw err;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
485
598
|
decision = await server.waitForDecision({ timeoutMs });
|
|
486
599
|
} catch (err) {
|
|
487
600
|
if (err && err.code === "GATE_TIMEOUT") {
|
|
488
601
|
// Expected outcome, not an error: exit 0 with the pending contract so the
|
|
489
602
|
// agent wraps up cleanly instead of reading a scary non-zero failure.
|
|
490
|
-
|
|
603
|
+
// pageLoaded is re-checked NOW (not serveAndOpen's early snapshot) so a
|
|
604
|
+
// tab that opened late still counts as "the user saw the gate".
|
|
605
|
+
process.stdout.write(
|
|
606
|
+
formatPlanPendingForHarness(deckDir, {
|
|
607
|
+
timeoutMs,
|
|
608
|
+
pageLoaded: server.pageLoaded(),
|
|
609
|
+
url: server.url,
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
491
612
|
return null;
|
|
492
613
|
}
|
|
493
614
|
throw err;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Red-thread backbone editing.
|
|
3
|
+
*
|
|
4
|
+
* The red thread is the structural backbone of the plan: one presentation
|
|
5
|
+
* move per slide, mirrored by one `### Slide N` storyboard subsection. This
|
|
6
|
+
* module maps a user-edited backbone (renamed, reordered, added, or removed
|
|
7
|
+
* slides) back onto the plan markdown in one pass: it rewrites the
|
|
8
|
+
* `## Red thread` bullets and restructures the per-slide sections to match —
|
|
9
|
+
* reordering, renumbering headings and storyboard `slide` fields, dropping
|
|
10
|
+
* removed slides, and inserting placeholder sections for new ones.
|
|
11
|
+
*
|
|
12
|
+
* Pure string logic — no React, no Office (unit-testable).
|
|
13
|
+
*
|
|
14
|
+
* 1:1 port of the add-in's red-thread-backbone.ts. Top-level helpers are
|
|
15
|
+
* prefixed `rt…` so the browser bundle (one IIFE across five modules) has no
|
|
16
|
+
* name collisions; exported API names match the add-in verbatim.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { extractRedThreadMessages, findRedThreadSection } from "./storyboard-markdown.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One slide in the user-edited backbone draft:
|
|
23
|
+
* { key: string, originalIndex: number | null, message: string }
|
|
24
|
+
* `originalIndex` is the index into the original plan's slide sections, or
|
|
25
|
+
* null for a new slide.
|
|
26
|
+
*/
|
|
27
|
+
export function backboneFromMessages(messages) {
|
|
28
|
+
return messages.map((message, index) => ({
|
|
29
|
+
key: `slide-${index}`,
|
|
30
|
+
originalIndex: index,
|
|
31
|
+
message,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RT_SLIDE_HEADING_RE = /^###\s+Slide\s+\d+.*$/gim;
|
|
36
|
+
const RT_NEXT_H2_IN_SECTION_RE = /^##\s/m;
|
|
37
|
+
|
|
38
|
+
function rtFindSlideSections(markdown) {
|
|
39
|
+
const starts = [];
|
|
40
|
+
for (const match of markdown.matchAll(RT_SLIDE_HEADING_RE)) {
|
|
41
|
+
starts.push(match.index ?? 0);
|
|
42
|
+
}
|
|
43
|
+
return starts.map((start, index) => {
|
|
44
|
+
let end = starts[index + 1] ?? markdown.length;
|
|
45
|
+
// A later `## ` heading closes the storyboard area before the doc ends;
|
|
46
|
+
// search after the section's own heading line.
|
|
47
|
+
const headingLineEnd = markdown.indexOf("\n", start);
|
|
48
|
+
const searchFrom =
|
|
49
|
+
headingLineEnd === -1 ? end : Math.min(headingLineEnd + 1, end);
|
|
50
|
+
const followUp = RT_NEXT_H2_IN_SECTION_RE.exec(markdown.slice(searchFrom, end));
|
|
51
|
+
if (followUp) end = searchFrom + followUp.index;
|
|
52
|
+
return { start, end, text: markdown.slice(start, end) };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The backbone is editable only when the red-thread moves and the slide
|
|
58
|
+
* sections line up one-to-one — otherwise a positional restructure could
|
|
59
|
+
* attach the wrong storyboard to a move.
|
|
60
|
+
*
|
|
61
|
+
* Returns { messages, sectionCount } or null.
|
|
62
|
+
*/
|
|
63
|
+
export function parsePlanBackbone(markdown) {
|
|
64
|
+
const messages = extractRedThreadMessages(markdown);
|
|
65
|
+
if (!messages.length) return null;
|
|
66
|
+
if (!findRedThreadSection(markdown)) return null;
|
|
67
|
+
const sections = rtFindSlideSections(markdown);
|
|
68
|
+
if (sections.length !== messages.length) return null;
|
|
69
|
+
return { messages, sectionCount: sections.length };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rtSanitizeMessage(message) {
|
|
73
|
+
return message.replace(/\s+/g, " ").trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rtRenumberSection(text, slideNumber, editedMessage) {
|
|
77
|
+
let next = text.replace(/^###\s+Slide\s+\d+/, `### Slide ${slideNumber}`);
|
|
78
|
+
if (editedMessage) {
|
|
79
|
+
next = next.replace(
|
|
80
|
+
/^(###\s+Slide\s+\d+)(?::[^\n]*)?$/m,
|
|
81
|
+
`$1: ${editedMessage}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return next.replace(/("slide"\s*:\s*)\d+/, `$1${slideNumber}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const RT_NEW_SLIDE_CONTENT_MAX = 28;
|
|
88
|
+
|
|
89
|
+
function rtPlaceholderSection(message, slideNumber) {
|
|
90
|
+
const content =
|
|
91
|
+
message.slice(0, RT_NEW_SLIDE_CONTENT_MAX).trim() || `Slide ${slideNumber}`;
|
|
92
|
+
const storyboard = {
|
|
93
|
+
version: 1,
|
|
94
|
+
slide: slideNumber,
|
|
95
|
+
blocks: [
|
|
96
|
+
{
|
|
97
|
+
id: "block-1",
|
|
98
|
+
x: 80,
|
|
99
|
+
y: 110,
|
|
100
|
+
w: 1440,
|
|
101
|
+
h: 300,
|
|
102
|
+
designElement: "Open canvas",
|
|
103
|
+
content,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
keyMessage: message,
|
|
107
|
+
};
|
|
108
|
+
return [
|
|
109
|
+
`### Slide ${slideNumber}: ${message}`,
|
|
110
|
+
"",
|
|
111
|
+
`Core message: ${message}`,
|
|
112
|
+
"",
|
|
113
|
+
"```storyboard-canvas",
|
|
114
|
+
JSON.stringify(storyboard, null, 2),
|
|
115
|
+
"```",
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Turns a staged backbone draft into the feedback round that asks the agent
|
|
121
|
+
* to build it: the restructured plan markdown plus a feedback message naming
|
|
122
|
+
* each added/renamed slide. Returns null when the draft cannot be applied or
|
|
123
|
+
* contains no content-affecting change.
|
|
124
|
+
*
|
|
125
|
+
* Returns { feedback, editedPlanMarkdown } or null.
|
|
126
|
+
*/
|
|
127
|
+
export function stageStoryIteration(markdown, slides) {
|
|
128
|
+
const editedPlanMarkdown = applyPlanBackbone(markdown, slides);
|
|
129
|
+
if (!editedPlanMarkdown) return null;
|
|
130
|
+
const committed = extractRedThreadMessages(markdown);
|
|
131
|
+
const changes = [];
|
|
132
|
+
slides.forEach((slide, index) => {
|
|
133
|
+
const position = index + 1;
|
|
134
|
+
if (slide.originalIndex === null) {
|
|
135
|
+
changes.push(`Added slide ${position} for the move "${slide.message}".`);
|
|
136
|
+
} else if (committed[slide.originalIndex] !== slide.message) {
|
|
137
|
+
changes.push(`Renamed slide ${position}'s move to "${slide.message}".`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
if (!changes.length) return null;
|
|
141
|
+
const feedback = [
|
|
142
|
+
"I edited the red thread.",
|
|
143
|
+
...changes,
|
|
144
|
+
"Build the storyboards and slide content for the new moves and adapt any renamed moves so the deck stays coherent, keeping the other slides intact.",
|
|
145
|
+
].join(" ");
|
|
146
|
+
return { feedback, editedPlanMarkdown };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Rewrites the plan markdown to match the edited backbone. Returns null when
|
|
151
|
+
* the plan's structure cannot be edited safely (see `parsePlanBackbone`).
|
|
152
|
+
*/
|
|
153
|
+
export function applyPlanBackbone(markdown, slides) {
|
|
154
|
+
const cleaned = slides
|
|
155
|
+
.map((slide) => ({ ...slide, message: rtSanitizeMessage(slide.message) }))
|
|
156
|
+
.filter((slide) => slide.message.length > 0);
|
|
157
|
+
if (!cleaned.length) return null;
|
|
158
|
+
|
|
159
|
+
const backbone = parsePlanBackbone(markdown);
|
|
160
|
+
const redThread = findRedThreadSection(markdown);
|
|
161
|
+
if (!backbone || !redThread) return null;
|
|
162
|
+
const sections = rtFindSlideSections(markdown);
|
|
163
|
+
|
|
164
|
+
const firstSection = sections[0];
|
|
165
|
+
const lastSection = sections[sections.length - 1];
|
|
166
|
+
if (!firstSection || !lastSection) return null;
|
|
167
|
+
if (firstSection.start < redThread.bodyEnd) return null;
|
|
168
|
+
|
|
169
|
+
const newRedThreadBody = `\n\n${cleaned
|
|
170
|
+
.map((slide) => `- ${slide.message}`)
|
|
171
|
+
.join("\n")}\n\n`;
|
|
172
|
+
|
|
173
|
+
const newSections = cleaned.map((slide, index) => {
|
|
174
|
+
const slideNumber = index + 1;
|
|
175
|
+
if (slide.originalIndex === null) {
|
|
176
|
+
return rtPlaceholderSection(slide.message, slideNumber);
|
|
177
|
+
}
|
|
178
|
+
const section = sections[slide.originalIndex];
|
|
179
|
+
if (!section) return rtPlaceholderSection(slide.message, slideNumber);
|
|
180
|
+
const originalMessage = backbone.messages[slide.originalIndex] ?? "";
|
|
181
|
+
const edited =
|
|
182
|
+
rtSanitizeMessage(originalMessage) !== slide.message ? slide.message : null;
|
|
183
|
+
return rtRenumberSection(section.text.trimEnd(), slideNumber, edited);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return [
|
|
187
|
+
markdown.slice(0, redThread.bodyStart),
|
|
188
|
+
newRedThreadBody,
|
|
189
|
+
markdown.slice(redThread.bodyEnd, firstSection.start),
|
|
190
|
+
newSections.join("\n\n"),
|
|
191
|
+
"\n",
|
|
192
|
+
markdown.slice(lastSection.end),
|
|
193
|
+
].join("");
|
|
194
|
+
}
|
|
@@ -144,6 +144,9 @@
|
|
|
144
144
|
".sm-meta{display:flex;flex-direction:column;gap:2px;}" +
|
|
145
145
|
".sm-meta .t{font-weight:600;font-size:13px;}" +
|
|
146
146
|
".sm-meta .h{font-size:11.5px;color:var(--sm-dim);}" +
|
|
147
|
+
".sm-note{max-width:min(560px,38vw);font-size:10.5px;line-height:1.35;color:#c7c7cf;" +
|
|
148
|
+
"border-left:2px solid var(--sm-accent);padding-left:10px;}" +
|
|
149
|
+
".sm-note b{font-weight:600;color:#f4f4f5;}" +
|
|
147
150
|
".sm-bar~.deck__rail,.deck__rail{top:var(--deck-chrome-h,62px);}" +
|
|
148
151
|
".sm-grow{flex:1;}" +
|
|
149
152
|
".sm-count{font-size:12px;color:var(--sm-dim);font-variant-numeric:tabular-nums;}" +
|
|
@@ -319,7 +322,7 @@
|
|
|
319
322
|
}
|
|
320
323
|
|
|
321
324
|
// ---------- chrome (top bar) ----------
|
|
322
|
-
var bar, sendBtn, countLabel;
|
|
325
|
+
var bar, sendBtn, approveBtn, countLabel;
|
|
323
326
|
function buildChrome() {
|
|
324
327
|
bar = el("div", "sm-o sm-bar");
|
|
325
328
|
bar.setAttribute("data-sm-overlay", "1");
|
|
@@ -335,6 +338,13 @@
|
|
|
335
338
|
meta.appendChild(el("div", "h", "Click any element to comment · ← → to switch slide"));
|
|
336
339
|
bar.appendChild(meta);
|
|
337
340
|
|
|
341
|
+
var note = el(
|
|
342
|
+
"div",
|
|
343
|
+
"sm-note",
|
|
344
|
+
"<b>Ask for slide changes here.</b> Direct PowerPoint edits after approval are not tracked and may be overwritten. HTML preview may differ slightly from final PowerPoint.",
|
|
345
|
+
);
|
|
346
|
+
bar.appendChild(note);
|
|
347
|
+
|
|
338
348
|
bar.appendChild(el("div", "sm-grow"));
|
|
339
349
|
|
|
340
350
|
countLabel = el("div", "sm-count");
|
|
@@ -347,8 +357,9 @@
|
|
|
347
357
|
};
|
|
348
358
|
bar.appendChild(sendBtn);
|
|
349
359
|
|
|
350
|
-
|
|
360
|
+
approveBtn = mkButton("Approve & continue", "primary", "check");
|
|
351
361
|
approveBtn.onclick = function () {
|
|
362
|
+
if (totalComments() > 0) return;
|
|
352
363
|
submit("approve");
|
|
353
364
|
};
|
|
354
365
|
bar.appendChild(approveBtn);
|
|
@@ -362,6 +373,11 @@
|
|
|
362
373
|
sendBtn.querySelector("span").textContent =
|
|
363
374
|
n > 0 ? "Send " + n + " comment" + (n === 1 ? "" : "s") : "Send comments";
|
|
364
375
|
sendBtn.classList.toggle("is-off", n === 0);
|
|
376
|
+
approveBtn.disabled = n > 0;
|
|
377
|
+
approveBtn.setAttribute("aria-disabled", n > 0 ? "true" : "false");
|
|
378
|
+
approveBtn.classList.toggle("is-off", n > 0);
|
|
379
|
+
approveBtn.title =
|
|
380
|
+
n > 0 ? "Send or delete active comments before approving." : "Approve and continue.";
|
|
365
381
|
}
|
|
366
382
|
|
|
367
383
|
// ---------- hover box (mirrors the add-in's hit-region-overlay) ----------
|
|
@@ -808,7 +824,12 @@
|
|
|
808
824
|
}
|
|
809
825
|
// After a comments round the tab stays open and polls the heartbeat so the
|
|
810
826
|
// NEXT run (which binds the same deterministic port) reloads this tab instead
|
|
811
|
-
// of opening a fresh browser window.
|
|
827
|
+
// of opening a fresh browser window. The poll reports this tab's round nonce
|
|
828
|
+
// and its visibility: only a VISIBLE tab may claim the next round for reuse —
|
|
829
|
+
// a tab buried in a background window must not swallow the round invisibly,
|
|
830
|
+
// so the server opens a fresh browser tab instead and answers `action:
|
|
831
|
+
// "close"` here, which retires this tab with a notice rather than reloading
|
|
832
|
+
// it into a hidden duplicate.
|
|
812
833
|
function startNextRoundPolling() {
|
|
813
834
|
var startedAt = Date.now();
|
|
814
835
|
var pollMs = CFG.pollMs || 1000;
|
|
@@ -820,17 +841,26 @@
|
|
|
820
841
|
showSessionEnded();
|
|
821
842
|
return;
|
|
822
843
|
}
|
|
823
|
-
|
|
844
|
+
var visible = document.visibilityState === "visible" ? "1" : "0";
|
|
845
|
+
var url = hb + (hb.indexOf("?") < 0 ? "?" : "&") +
|
|
846
|
+
"round=" + encodeURIComponent(CFG.round || "") + "&visible=" + visible;
|
|
847
|
+
fetch(url, { cache: "no-store" })
|
|
824
848
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
825
849
|
.then(function (data) {
|
|
826
|
-
//
|
|
850
|
+
// Act only on a genuine NEWER slidemind review round: shape-guard the
|
|
827
851
|
// service marker (a foreign app may have taken the port) and require a
|
|
828
852
|
// different nonce (same nonce = this run's own orphan — never self-reload).
|
|
829
853
|
if (!data || data.service !== "slidemind-review" || !data.round) return;
|
|
830
|
-
if (data.round
|
|
854
|
+
if (data.round === CFG.round) return;
|
|
855
|
+
if (data.action === "close") {
|
|
856
|
+
// The new round opened in a fresh browser tab — this one is stale.
|
|
831
857
|
clearInterval(timer);
|
|
832
|
-
|
|
858
|
+
showSuperseded();
|
|
859
|
+
return;
|
|
833
860
|
}
|
|
861
|
+
if (data.action === "wait") return; // grace window still deciding — keep polling
|
|
862
|
+
clearInterval(timer);
|
|
863
|
+
markRoundReadyAndReload();
|
|
834
864
|
})
|
|
835
865
|
.catch(function () { /* server down between rounds — keep polling */ });
|
|
836
866
|
}, pollMs);
|
|
@@ -871,6 +901,13 @@
|
|
|
871
901
|
function showSessionEnded() {
|
|
872
902
|
showVeil("<span>Session ended</span>", "You can close this tab.");
|
|
873
903
|
}
|
|
904
|
+
function showSuperseded() {
|
|
905
|
+
document.title = "Review moved — slidemind";
|
|
906
|
+
showVeil(
|
|
907
|
+
"<span>Continued in a new tab</span>",
|
|
908
|
+
"The next review round opened in a fresh browser tab — you can close this one.",
|
|
909
|
+
);
|
|
910
|
+
}
|
|
874
911
|
|
|
875
912
|
// ---------- slide tracking ----------
|
|
876
913
|
function syncSlideFromNav() {
|