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.
@@ -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 markdown renderer for the live preview, then the overlay script
228
- // itself. `planMdJs` is the raw plan-md.mjs source — a dependency-free ESM
229
- // module, so stripping its `export ` keywords makes it plain browser JS; it is
230
- // wrapped in an IIFE that exposes window.__SLIDEMIND_RENDER_MD__ for the
231
- // overlay's live re-render. Kept a pure function of its inputs so it's
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
- planMdJs,
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 rendererSrc = String(planMdJs || "")
256
- .replace(/^export\s+/gm, "")
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
- planMdJs,
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 planMd = planMdJs ?? fs.readFileSync(PLAN_MD_PATH, "utf8");
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
- planMdJs: planMd,
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
- hb.respond(res);
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 = openInBrowser,
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
- openedLine:
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
- process.stdout.write(formatPlanPendingForHarness(deckDir, { timeoutMs }));
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
- var approveBtn = mkButton("Approve & continue", "primary", "check");
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
- fetch(hb, { cache: "no-store" })
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
- // Reload only for a genuine NEWER slidemind review round: shape-guard the
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 !== CFG.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
- markRoundReadyAndReload();
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() {