slidemind 0.2.3 → 0.3.0

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.
@@ -7,11 +7,36 @@
7
7
  // - Every intrinsic element carries data-sm-loc="slides/<f>.jsx:line:col"
8
8
  // (stamped by build.mjs's Babel plugin).
9
9
  // - Click anywhere on the stage -> resolve the innermost element under the
10
- // click via closest('[data-sm-loc]') and its ancestor chain, drop a pin,
11
- // capture a note. Slide-level notes (empty-area clicks) carry no element.
12
- // - Previous round's comments render as read-only ghost pins.
13
- // - Approve, or Send comments -> POST to the local server, which unblocks the
14
- // `slidemind review` command so the harness proceeds. No copy-paste.
10
+ // click via closest('[data-sm-loc]'), drop a pin, capture a note.
11
+ // Slide-level notes (empty-area clicks) carry no element.
12
+ // - Saving a pin SENDS it (there is no batch "submit" button): the gate
13
+ // resolves at once and the `slidemind review` command returns with it.
14
+ // - Approve -> POST to the local server, which unblocks the command with the
15
+ // sign-off. No copy-paste.
16
+ //
17
+ // TWO MODES, not "reviewing vs. veiled over":
18
+ // - PARKED (the command is blocking on this tab): saving a pin resolves the
19
+ // gate right now, carrying any queued stragglers with it; Approve is live
20
+ // while nothing is outstanding.
21
+ // - WORKING (the agent is fixing the slides): the preview stays FULLY
22
+ // browsable and pinnable — saved pins QUEUE in sessionStorage (ledger
23
+ // status "queued") instead of hitting the server — and only Approve is off
24
+ // (the displayed render is already superseded). A status strip says so.
25
+ //
26
+ // Queued pins are never lost: the tab polls the heartbeat, and when the agent's
27
+ // next `slidemind review` run claims it the reload's boot POSTs the whole
28
+ // backlog to /review/backlog — which resolves that run immediately as a drain
29
+ // round, so the tab simply stays in working mode with the ledger flipped to
30
+ // "sent". A tab superseded by a fresh window flushes before retiring, likewise.
31
+ //
32
+ // ONE LEDGER, three statuses. Every pin is {id, round, jsxPath, resolvedTarget,
33
+ // x, y, text, status} (x/y are full-precision 0..1 fractions of the slide):
34
+ // queued (typed here, not yet delivered) -> sent (the agent has it) ->
35
+ // addressed (a newer gate run served this page — the server's round nonce
36
+ // changed). Addressed pins are the read-only GHOST pins of previous rounds —
37
+ // there is no separate previous-round channel. A tab with no session of its own
38
+ // seeds the ledger from CFG.ledgerSeed (the deck's persisted review.json) as
39
+ // SENT rows: the agent has just been handed them, nothing has answered them.
15
40
  //
16
41
  // Branding (lifted from the add-in's product identity):
17
42
  // - The embossed "raised slide" logo (public/logo.svg).
@@ -27,16 +52,45 @@
27
52
  var STAGE_SEL = "#stage";
28
53
  var HEADER_H = 62;
29
54
  var SIDEBAR_W = 316; // panel + gaps reserved on the right when comments exist
30
- var commentsBySlide = {}; // stem -> [comment] (this round, editable)
31
- var previousBySlide = {}; // stem -> [comment] (last round, read-only ghosts)
55
+ // The ledger IS the state: one flat list of this session's pins. Pins, sidebar
56
+ // rows and the approve guard are all derived from it, so a comment keeps its
57
+ // identity across queued -> sent -> addressed (and across the round reload).
58
+ var ledger = [];
59
+ var roundNo = 1; // this tab's round counter (sessionStorage, not server state)
32
60
  var currentStem = null;
61
+ var pendingStem = null; // the slide to re-anchor on after a round reload
33
62
  var draftPopover = null;
34
- var submitted = false;
63
+ var approved = false; // approve latch — the only thing that ends the session
64
+ var working = false; // the agent is fixing → pins queue instead of resolving
65
+ var workingNote = null; // extra line for the working strip (queued / superseded)
35
66
  var sidebarEl = null;
36
67
  var sidebarOpen = false;
37
68
  var hoverBox = null;
38
69
  var locked = false; // hover box pinned to a popover's target
39
70
 
71
+ // The shared gate session (src/builders/gate-session.js, inlined into the page
72
+ // as window.__SLIDEMIND_GATE__): the deck-keyed sessionStorage ledger, the
73
+ // backlog flush, the server-nonce round transition, the round-ready announce
74
+ // and the heartbeat poll — one implementation, shared with the plan gate.
75
+ var SES = window.__SLIDEMIND_GATE__.createGateSession({
76
+ name: "review",
77
+ deck: CFG.deck || "",
78
+ storage: sessionStorage,
79
+ fetch: function (u, o) {
80
+ return fetch(u, o);
81
+ },
82
+ round: CFG.round || "",
83
+ service: "slidemind-review",
84
+ heartbeat: CFG.heartbeat || "/__review/heartbeat",
85
+ backlogEndpoint: CFG.backlogEndpoint || "/review/backlog",
86
+ pollMs: CFG.pollMs,
87
+ pollTtlMs: CFG.pollTtlMs,
88
+ roundInSentLabel: true, // "sent · round N" — a pin names the render it sits on
89
+ attentionTitle: "● Preview ready — slidemind",
90
+ notifyTitle: "slidemind — preview ready",
91
+ notifyBody: "The revised slides are ready for another look. Click to jump back to the tab.",
92
+ });
93
+
40
94
  // UI accent for the hover box + focus rings (product chrome). The brand
41
95
  // colour now lives entirely in the orb PNG (CFG.logoMark) served from the
42
96
  // bundle — the CLI no longer re-derives an orb from the CD's colours.
@@ -46,12 +100,6 @@
46
100
  // the sidebar jump to any slide by synthesising the deck's arrow-key nav.
47
101
  var slideOrder = [];
48
102
 
49
- (CFG.previousComments || []).forEach(function (c) {
50
- var stem = stemOf(c && c.jsxPath);
51
- if (!stem) return;
52
- (previousBySlide[stem] = previousBySlide[stem] || []).push(c);
53
- });
54
-
55
103
  // ---------- brand assets ----------
56
104
  // The add-in's embossed "raised slide" mark (src/ui/public/logo.svg), inlined.
57
105
  var LOGO_SVG =
@@ -150,6 +198,15 @@
150
198
  ".sm-bar~.deck__rail,.deck__rail{top:var(--deck-chrome-h,62px);}" +
151
199
  ".sm-grow{flex:1;}" +
152
200
  ".sm-count{font-size:12px;color:var(--sm-dim);font-variant-numeric:tabular-nums;}" +
201
+ /* working strip: the agent is fixing — the preview stays live behind it */
202
+ ".sm-status{display:none;align-items:center;gap:9px;max-width:min(420px,32vw);padding:7px 12px;border-radius:10px;" +
203
+ "border:1px solid color-mix(in srgb,var(--sm-accent) 32%,transparent);" +
204
+ "background:color-mix(in srgb,var(--sm-accent) 10%,transparent);font-size:11.5px;line-height:1.4;color:#d7d7de;}" +
205
+ ".sm-status.on{display:flex;}" +
206
+ ".sm-status b{display:block;font-weight:600;font-size:12px;color:#f4f4f5;}" +
207
+ ".sm-spin{width:11px;height:11px;flex-shrink:0;border-radius:50%;border:2px solid var(--sm-accent);" +
208
+ "border-top-color:transparent;animation:sm-spin 0.9s linear infinite;}" +
209
+ "@keyframes sm-spin{to{transform:rotate(360deg)}}" +
153
210
  /* buttons */
154
211
  ".sm-btn{display:inline-flex;align-items:center;gap:7px;border:1px solid transparent;border-radius:9px;" +
155
212
  "padding:8px 14px;font-family:var(--sm-font);font-weight:600;font-size:12.5px;cursor:pointer;" +
@@ -169,7 +226,10 @@
169
226
  "font:600 12px var(--sm-font);cursor:pointer;display:flex;align-items:center;justify-content:center;" +
170
227
  "pointer-events:auto;animation:sm-pin-in .26s cubic-bezier(.2,1.5,.4,1);transition:filter .12s;}" +
171
228
  ".sm-pin:hover{filter:brightness(1.12);}" +
229
+ /* queued (typed here, not delivered yet) / sent (the agent has it) /
230
+ addressed (answered by a newer render — the read-only ghost) */
172
231
  ".sm-pin-active{background:#161618;color:#fff;border:2px solid rgba(255,255,255,.92);box-shadow:0 2px 9px rgba(0,0,0,.55);}" +
232
+ ".sm-pin-sent{background:#161618;color:var(--sm-accent);border:2px dashed var(--sm-accent);box-shadow:0 2px 9px rgba(0,0,0,.45);}" +
173
233
  ".sm-pin-ghost{background:rgba(20,20,22,.72);color:#a1a1aa;border:1.5px dashed #71717a;}" +
174
234
  /* popover */
175
235
  ".sm-pop{position:fixed;z-index:2147483646;width:272px;padding:13px;background:var(--sm-surface);" +
@@ -223,7 +283,12 @@
223
283
  "font-size:10px;font-weight:600;margin-top:1px;background:#161618;color:#fff;border:1.5px solid rgba(255,255,255,.85);}" +
224
284
  ".sm-side-chip.ghost{background:rgba(20,20,22,.6);color:#a1a1aa;border:1.5px dashed #71717a;}" +
225
285
  ".sm-side-txt{font-size:12.5px;line-height:1.4;color:#e4e4e7;word-break:break-word;}" +
226
- ".sm-side-loc{font-size:10.5px;color:var(--sm-dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin-top:2px;word-break:break-all;}";
286
+ ".sm-side-loc{font-size:10.5px;color:var(--sm-dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin-top:2px;word-break:break-all;}" +
287
+ /* ledger status chip — where each pin stands, per row */
288
+ ".sm-side-state{display:inline-block;margin-top:4px;padding:1px 7px;border-radius:999px;font-size:9.5px;font-weight:600;" +
289
+ "letter-spacing:.05em;text-transform:uppercase;border:1px solid var(--sm-border);color:var(--sm-dim);}" +
290
+ '.sm-side-state[data-state="queued"]{border-color:color-mix(in srgb,var(--sm-accent) 45%,transparent);color:var(--sm-accent);}' +
291
+ '.sm-side-state[data-state="sent"]{border-color:color-mix(in srgb,var(--sm-accent) 30%,transparent);color:#c6d4f5;}';
227
292
 
228
293
  // ---------- helpers ----------
229
294
  function stemOf(jsxPath) {
@@ -239,15 +304,31 @@
239
304
  function stage() {
240
305
  return document.querySelector(STAGE_SEL);
241
306
  }
307
+ // Click position, a FULL-PRECISION 0..1 fraction of the slide end to end —
308
+ // on the wire and in review.json. Whole percent is a display unit only (the
309
+ // harness output rounds); persisting it drifted a pin by up to 6px on a
310
+ // 1280px stage and collapsed every sub-1% offset to the edge.
311
+ function fx(c) {
312
+ return clamp01(Number(c.x) || 0);
313
+ }
314
+ function fy(c) {
315
+ return clamp01(Number(c.y) || 0);
316
+ }
317
+ function commentsForStem(stem) {
318
+ return ledger.filter(function (c) {
319
+ return stemOf(c.jsxPath) === stem;
320
+ });
321
+ }
242
322
  function activeComments() {
243
- return commentsBySlide[currentStem] || [];
323
+ return commentsForStem(currentStem);
244
324
  }
245
- function totalComments() {
246
- var n = 0;
247
- Object.keys(commentsBySlide).forEach(function (k) {
248
- n += commentsBySlide[k].length;
325
+ // Outstanding = written but not yet answered by a render. This is the approve
326
+ // guard: signing off while something is outstanding would approve slides the
327
+ // agent has been told to change.
328
+ function openComments() {
329
+ return ledger.filter(function (c) {
330
+ return c.status === "queued" || c.status === "sent";
249
331
  });
250
- return n;
251
332
  }
252
333
  function el(tag, cls, html) {
253
334
  var e = document.createElement(tag);
@@ -278,18 +359,12 @@
278
359
  b.innerHTML = (iconName ? ICONS[iconName] : "") + "<span>" + label + "</span>";
279
360
  return b;
280
361
  }
281
- function mkButton(label, variant, iconName) {
282
- var b = el("button", "sm-btn sm-btn-" + variant);
283
- b.innerHTML = (iconName ? ICONS[iconName] : "") + "<span>" + label + "</span>";
284
- return b;
285
- }
286
362
 
287
- // Build the resolvedTarget HitRegion + ancestor locs for an element, in the
288
- // same shape the add-in's preview-review uses, so the harness-side formatter
289
- // ports verbatim.
363
+ // Build the resolvedTarget HitRegion for an element, in the same shape the
364
+ // add-in's preview-review uses, so the harness-side formatter ports verbatim.
290
365
  function describeTarget(elx) {
291
366
  var st = stage();
292
- if (!elx || !st) return { region: null, ancestors: [] };
367
+ if (!elx || !st) return null;
293
368
  var sr = st.getBoundingClientRect();
294
369
  var r = elx.getBoundingClientRect();
295
370
  var cls =
@@ -298,31 +373,52 @@
298
373
  : elx.getAttribute("class") || "";
299
374
  var depth = 0;
300
375
  for (var q = elx.parentElement; q && q !== st; q = q.parentElement) depth++;
301
- var ancestors = [];
302
- for (var p = elx.parentElement; p && p !== st; p = p.parentElement) {
303
- var aloc = p.getAttribute && p.getAttribute("data-sm-loc");
304
- if (aloc) ancestors.push(aloc);
305
- }
306
376
  return {
307
- region: {
308
- loc: elx.getAttribute("data-sm-loc"),
309
- tag: elx.tagName.toLowerCase(),
310
- className: cls,
311
- textSnippet: (elx.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80),
312
- bbox: {
313
- x: (r.left - sr.left) / sr.width,
314
- y: (r.top - sr.top) / sr.height,
315
- w: r.width / sr.width,
316
- h: r.height / sr.height,
317
- },
318
- depth: depth,
377
+ loc: elx.getAttribute("data-sm-loc"),
378
+ tag: elx.tagName.toLowerCase(),
379
+ className: cls,
380
+ textSnippet: (elx.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80),
381
+ bbox: {
382
+ x: (r.left - sr.left) / sr.width,
383
+ y: (r.top - sr.top) / sr.height,
384
+ w: r.width / sr.width,
385
+ h: r.height / sr.height,
319
386
  },
320
- ancestors: ancestors,
387
+ depth: depth,
321
388
  };
322
389
  }
323
390
 
391
+ // ---------- session (shared with the plan gate via gate-session.js) ----------
392
+ function saveLedger() {
393
+ SES.saveLedger(ledger);
394
+ }
395
+ // The context the NEXT round restores from: the server nonce (the round
396
+ // marker, added by the shared session) plus which slide the reviewer was on.
397
+ function saveCtx() {
398
+ SES.saveCtx({ round: roundNo, stem: currentStem });
399
+ }
400
+ function queuedComments() {
401
+ return SES.queued(ledger);
402
+ }
403
+ function flushBacklog() {
404
+ return SES.flushBacklog(ledger);
405
+ }
406
+
407
+ // Restore this tab's session and work out whether THIS load is a new round.
408
+ // Runs before the first render so the chrome is built in the right mode.
409
+ function restoreSession() {
410
+ var st = SES.restore(CFG.ledgerSeed);
411
+ ledger = st.ledger;
412
+ roundNo = st.roundNo;
413
+ working = st.working;
414
+ // Reviewer context survives the round: come back to the same slide.
415
+ if (st.ctx && typeof st.ctx.stem === "string" && st.ctx.stem) pendingStem = st.ctx.stem;
416
+ saveLedger();
417
+ saveCtx();
418
+ }
419
+
324
420
  // ---------- chrome (top bar) ----------
325
- var bar, sendBtn, approveBtn, countLabel;
421
+ var bar, approveBtn, countLabel, statusEl;
326
422
  function buildChrome() {
327
423
  bar = el("div", "sm-o sm-bar");
328
424
  bar.setAttribute("data-sm-overlay", "1");
@@ -338,10 +434,15 @@
338
434
  meta.appendChild(el("div", "h", "Click any element to comment · ← → to switch slide"));
339
435
  bar.appendChild(meta);
340
436
 
437
+ // The working strip: shown instead of the old full-screen waiting veil, so
438
+ // the preview behind it stays readable and pinnable while the agent fixes.
439
+ statusEl = el("div", "sm-o sm-status");
440
+ bar.appendChild(statusEl);
441
+
341
442
  var note = el(
342
443
  "div",
343
444
  "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.",
445
+ "<b>Ask for slide changes here.</b> Direct PowerPoint edits after approval are not tracked and may be overwritten. Preview may differ slightly from final PowerPoint.",
345
446
  );
346
447
  bar.appendChild(note);
347
448
 
@@ -350,34 +451,55 @@
350
451
  countLabel = el("div", "sm-count");
351
452
  bar.appendChild(countLabel);
352
453
 
353
- sendBtn = mkButton("Send comments", "ghost", "message");
354
- sendBtn.onclick = function () {
355
- if (totalComments() === 0) return;
356
- submit("comments");
357
- };
358
- bar.appendChild(sendBtn);
359
-
360
454
  approveBtn = mkButton("Approve & continue", "primary", "check");
361
- approveBtn.onclick = function () {
362
- if (totalComments() > 0) return;
363
- submit("approve");
364
- };
455
+ approveBtn.onclick = approve;
365
456
  bar.appendChild(approveBtn);
366
457
 
367
458
  document.body.appendChild(bar);
368
459
  refreshChrome();
369
460
  }
461
+ // Approve is refused while the agent is fixing (the render on screen is
462
+ // superseded) or while pins are QUEUED here — sending them is the reviewer's
463
+ // own next action, and approving would drop them. It is NOT refused for
464
+ // already-delivered ("sent") pins: the same rule the plan gate uses. Guarding
465
+ // on those deadlocked the gate — a sent pin only clears on a round transition,
466
+ // so a round that touched no file left Approve off for the rest of the session.
370
467
  function refreshChrome() {
371
- var n = totalComments();
372
- countLabel.textContent = n === 0 ? "no comments yet" : n + (n === 1 ? " comment" : " comments");
373
- sendBtn.querySelector("span").textContent =
374
- n > 0 ? "Send " + n + " comment" + (n === 1 ? "" : "s") : "Send comments";
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.";
468
+ if (!countLabel) return;
469
+ var open = openComments().length;
470
+ var q = queuedComments().length;
471
+ countLabel.textContent =
472
+ ledger.length === 0
473
+ ? "no comments yet"
474
+ : open + " open · " + ledger.length + " total";
475
+ var off = working || q > 0;
476
+ approveBtn.disabled = off;
477
+ approveBtn.setAttribute("aria-disabled", off ? "true" : "false");
478
+ approveBtn.classList.toggle("is-off", off);
479
+ approveBtn.title = working
480
+ ? "The agent is fixing the slides — approve once the next round arrives."
481
+ : q > 0
482
+ ? "Send your queued comments first."
483
+ : "Approve and continue.";
484
+ statusEl.classList.toggle("on", working);
485
+ if (working) {
486
+ statusEl.innerHTML = '<div class="sm-spin"></div>';
487
+ var txt = el("div");
488
+ txt.appendChild(el("div", null, "<b>The agent is fixing the slides</b>"));
489
+ txt.appendChild(
490
+ el(
491
+ "div",
492
+ null,
493
+ "",
494
+ ),
495
+ ).textContent =
496
+ (workingNote ? workingNote + " " : "") +
497
+ (q
498
+ ? q + (q === 1 ? " comment is" : " comments are") +
499
+ " queued — they're delivered with the next round."
500
+ : "Keep browsing and keep commenting — new pins are picked up automatically.");
501
+ statusEl.appendChild(txt);
502
+ }
381
503
  }
382
504
 
383
505
  // ---------- hover box (mirrors the add-in's hit-region-overlay) ----------
@@ -440,38 +562,45 @@
440
562
  renderPins();
441
563
  }
442
564
 
443
- // ---------- sidebar (the add-in's comment list) ----------
565
+ // ---------- ledger rail (the add-in's comment list, with status) ----------
444
566
  function commentScreenPoint(c) {
445
567
  var sr = stage().getBoundingClientRect();
446
568
  return {
447
- x: sr.left + clamp01(c.point.x) * sr.width,
448
- y: sr.top + clamp01(c.point.y) * sr.height,
569
+ x: sr.left + fx(c) * sr.width,
570
+ y: sr.top + fy(c) * sr.height,
449
571
  };
450
572
  }
451
- function openCommentAt(c, ghost) {
573
+ // Only a QUEUED pin is still the reviewer's to change; once it is with the
574
+ // agent it is read-only history.
575
+ function openCommentAt(c) {
452
576
  var p = commentScreenPoint(c);
453
- if (ghost) openGhost(c, p.x, p.y);
454
- else openEditor(c, p.x, p.y);
577
+ if (c.status === "queued") openEditor(c, p.x, p.y);
578
+ else openGhost(c, p.x, p.y);
579
+ }
580
+ function statusLabel(c) {
581
+ return SES.statusLabel(c);
455
582
  }
456
- function sideRow(stem, c, num, ghost) {
583
+ function sideRow(stem, c, num) {
457
584
  var r = el("div", "sm-side-row");
458
- var chip = el("div", "sm-side-chip" + (ghost ? " ghost" : ""));
585
+ var chip = el("div", "sm-side-chip" + (c.status === "addressed" ? " ghost" : ""));
459
586
  chip.textContent = String(num);
460
587
  var txt = el("div");
461
- txt.appendChild(el("div", "sm-side-txt", "")).textContent = c.comment;
588
+ txt.appendChild(el("div", "sm-side-txt", "")).textContent = c.text;
462
589
  var locStr = c.resolvedTarget && c.resolvedTarget.loc ? c.resolvedTarget.loc : "slide-level note";
463
590
  txt.appendChild(el("div", "sm-side-loc", "")).textContent = locStr;
591
+ var state = txt.appendChild(el("div", "sm-side-state", ""));
592
+ state.setAttribute("data-state", c.status);
593
+ state.textContent = statusLabel(c);
464
594
  r.appendChild(chip);
465
595
  r.appendChild(txt);
466
596
  r.onclick = function () {
467
597
  goToStem(stem);
468
- openCommentAt(c, ghost);
598
+ openCommentAt(c);
469
599
  };
470
600
  return r;
471
601
  }
472
602
  function updateSidebar() {
473
- var show = totalComments() > 0;
474
- if (!show) {
603
+ if (!ledger.length) {
475
604
  if (sidebarEl) {
476
605
  sidebarEl.remove();
477
606
  sidebarEl = null;
@@ -488,36 +617,44 @@
488
617
  applyStageFit();
489
618
  }
490
619
  sidebarEl.innerHTML = "";
491
- var n = totalComments();
492
620
  var head = el(
493
621
  "div",
494
622
  "sm-side-head",
495
- ICONS.message + "<span>Comments</span><span class=\"n\">" + n + "</span>",
623
+ ICONS.message +
624
+ "<span>Comments</span><span class=\"n\">" +
625
+ openComments().length +
626
+ " open</span>",
496
627
  );
497
628
  var body = el("div", "sm-side-body");
498
- var stems = slideOrder.length
499
- ? slideOrder
500
- : Object.keys(commentsBySlide); // fallback if order unknown
629
+ var seen = {};
630
+ var stems = slideOrder.slice();
631
+ // A pin whose slide is not in the play order (renamed file) still gets a
632
+ // group — the ledger never silently drops a comment the user wrote.
633
+ ledger.forEach(function (c) {
634
+ var s = stemOf(c.jsxPath);
635
+ if (s && stems.indexOf(s) < 0) stems.push(s);
636
+ });
501
637
  stems.forEach(function (stem) {
502
- var act = commentsBySlide[stem] || [];
503
- var gh = previousBySlide[stem] || [];
504
- if (!act.length && !gh.length) return;
638
+ if (!stem || seen[stem]) return;
639
+ seen[stem] = true;
640
+ var rows = commentsForStem(stem);
641
+ if (!rows.length) return;
505
642
  var grp = el("div", "sm-side-grp");
506
643
  grp.appendChild(el("div", "sm-side-grp-h", "")).textContent = stem;
507
- act.forEach(function (c, i) {
508
- grp.appendChild(sideRow(stem, c, i + 1, false));
644
+ rows.forEach(function (c, i) {
645
+ grp.appendChild(sideRow(stem, c, i + 1));
509
646
  });
510
- if (gh.length) {
511
- grp.appendChild(el("div", "sm-side-sub", "Previous round"));
512
- gh.forEach(function (c, i) {
513
- grp.appendChild(sideRow(stem, c, i + 1, true));
514
- });
515
- }
516
647
  body.appendChild(grp);
517
648
  });
518
649
  sidebarEl.appendChild(head);
519
650
  sidebarEl.appendChild(body);
520
651
  }
652
+ // One repaint of everything the ledger drives.
653
+ function renderAll() {
654
+ renderPins();
655
+ refreshChrome();
656
+ updateSidebar();
657
+ }
521
658
 
522
659
  // Jump the deck to a slide by synthesising its arrow-key navigation.
523
660
  function goToStem(stem) {
@@ -541,32 +678,35 @@
541
678
  pinLayer.setAttribute("data-sm-overlay", "1");
542
679
  document.body.appendChild(pinLayer);
543
680
  }
681
+ // Every pin of this slide, whatever its status — the addressed ones ARE the
682
+ // ghost pins of earlier rounds (numbering matches the sidebar rows).
544
683
  function renderPins() {
545
684
  if (!pinLayer) return;
546
685
  pinLayer.innerHTML = "";
547
686
  var st = stage();
548
687
  if (!st) return;
549
688
  var sr = st.getBoundingClientRect();
550
- (previousBySlide[currentStem] || []).forEach(function (c, i) {
551
- pinLayer.appendChild(makePin(c, sr, i + 1, true));
552
- });
553
689
  activeComments().forEach(function (c, i) {
554
- pinLayer.appendChild(makePin(c, sr, i + 1, false));
690
+ pinLayer.appendChild(makePin(c, sr, i + 1));
555
691
  });
556
692
  }
557
- function makePin(c, sr, num, ghost) {
558
- var pin = el("button", "sm-pin " + (ghost ? "sm-pin-ghost" : "sm-pin-active"));
693
+ function pinVariant(c) {
694
+ if (c.status === "queued") return "sm-pin-active";
695
+ if (c.status === "sent") return "sm-pin-sent";
696
+ return "sm-pin-ghost";
697
+ }
698
+ function makePin(c, sr, num) {
699
+ var pin = el("button", "sm-pin " + pinVariant(c));
559
700
  pin.setAttribute("data-sm-overlay", "1");
560
701
  pin.textContent = String(num);
561
- pin.title = c.comment || "";
562
- pin.style.left = sr.left + clamp01(c.point.x) * sr.width + "px";
563
- pin.style.top = sr.top + clamp01(c.point.y) * sr.height + "px";
702
+ pin.title = c.text || "";
703
+ var px = sr.left + fx(c) * sr.width;
704
+ var py = sr.top + fy(c) * sr.height;
705
+ pin.style.left = px + "px";
706
+ pin.style.top = py + "px";
564
707
  pin.onclick = function (e) {
565
708
  e.stopPropagation();
566
- var px = sr.left + clamp01(c.point.x) * sr.width;
567
- var py = sr.top + clamp01(c.point.y) * sr.height;
568
- if (ghost) openGhost(c, px, py);
569
- else openEditor(c, px, py);
709
+ openCommentAt(c);
570
710
  };
571
711
  return pin;
572
712
  }
@@ -595,12 +735,15 @@
595
735
  document.body.appendChild(pop);
596
736
  return pop;
597
737
  }
738
+ // The label is TEXT, not markup: targetLabel returns "<h1.hero>", which the
739
+ // browser would otherwise parse into an actual (empty) element — the pinned
740
+ // element's name simply vanished from the popover header.
598
741
  function head(text, isElement) {
599
- return el(
600
- "div",
601
- "sm-pop-head",
602
- ICONS.pin + '<span class="' + (isElement ? "mono" : "") + '">' + text + "</span>",
603
- );
742
+ var h = el("div", "sm-pop-head", ICONS.pin);
743
+ var span = el("span", isElement ? "mono" : null);
744
+ span.textContent = text;
745
+ h.appendChild(span);
746
+ return h;
604
747
  }
605
748
  function targetLabel(region) {
606
749
  if (!region) return "Slide-level note";
@@ -614,7 +757,10 @@
614
757
  });
615
758
  }
616
759
 
617
- function openDraft(point, region, ancestors, px, py) {
760
+ // Saving a pin SENDS it — there is no batch submit. The button label says so
761
+ // in each mode, because the consequence differs: parked resolves the gate now,
762
+ // working queues for the agent's next run.
763
+ function openDraft(point, region, px, py) {
618
764
  var pop = makePopover(px, py);
619
765
  pop.appendChild(head(targetLabel(region), !!region));
620
766
  var ta = el("textarea", "sm-ta");
@@ -622,22 +768,22 @@
622
768
  var row = el("div", "sm-row end");
623
769
  var cancel = mkButton("Cancel", "ghost", "close");
624
770
  cancel.onclick = closePopover;
625
- var save = mkButton("Add comment", "primary", "check");
771
+ var save = mkButton(working ? "Queue comment" : "Send comment", "primary", "check");
626
772
  function doSave() {
627
773
  var text = ta.value.trim();
628
774
  if (!text) return;
629
- (commentsBySlide[currentStem] = commentsBySlide[currentStem] || []).push({
775
+ closePopover();
776
+ sendComment({
630
777
  id: genId(),
778
+ round: roundNo,
631
779
  jsxPath: "slides/" + currentStem + ".jsx",
632
- point: point,
633
780
  resolvedTarget: region,
634
- ancestors: ancestors,
635
- comment: text,
781
+ x: clamp01(point.x),
782
+ y: clamp01(point.y),
783
+ text: text,
784
+ status: "queued",
785
+ at: new Date().toISOString(),
636
786
  });
637
- closePopover();
638
- renderPins();
639
- refreshChrome();
640
- updateSidebar();
641
787
  }
642
788
  save.onclick = doSave;
643
789
  pop.appendChild(ta);
@@ -649,31 +795,31 @@
649
795
  bindTextareaKeys(ta, doSave);
650
796
  }
651
797
 
798
+ // Only reachable for a QUEUED pin (openCommentAt routes the rest to the
799
+ // read-only view): until it is delivered, the reviewer can still fix or drop it.
652
800
  function openEditor(comment, px, py) {
653
801
  var pop = makePopover(px, py);
654
802
  pop.appendChild(head(targetLabel(comment.resolvedTarget), !!comment.resolvedTarget));
655
803
  var ta = el("textarea", "sm-ta");
656
- ta.value = comment.comment;
804
+ ta.value = comment.text;
657
805
  var row = el("div", "sm-row split");
658
806
  var del = mkButton("Delete", "danger", "trash");
659
807
  function doDelete() {
660
- var list = commentsBySlide[currentStem] || [];
661
- var idx = list.indexOf(comment);
662
- if (idx >= 0) list.splice(idx, 1);
808
+ var idx = ledger.indexOf(comment);
809
+ if (idx >= 0) ledger.splice(idx, 1);
663
810
  closePopover();
664
- renderPins();
665
- refreshChrome();
666
- updateSidebar();
811
+ saveLedger();
812
+ renderAll();
667
813
  }
668
814
  del.onclick = doDelete;
669
815
  var save = mkButton("Save", "primary", "check");
670
816
  function doSave() {
671
817
  var text = ta.value.trim();
672
818
  if (!text) return doDelete();
673
- comment.comment = text;
819
+ comment.text = text;
674
820
  closePopover();
675
- renderPins();
676
- updateSidebar();
821
+ saveLedger();
822
+ renderAll();
677
823
  }
678
824
  save.onclick = doSave;
679
825
  pop.appendChild(ta);
@@ -688,130 +834,94 @@
688
834
  function openGhost(comment, px, py) {
689
835
  var pop = makePopover(px, py);
690
836
  pop.appendChild(
691
- head("Previous round · " + targetLabel(comment.resolvedTarget), !!comment.resolvedTarget),
837
+ head(
838
+ "Round " + comment.round + " · " + statusLabel(comment) + " · " +
839
+ targetLabel(comment.resolvedTarget),
840
+ !!comment.resolvedTarget,
841
+ ),
692
842
  );
693
- pop.appendChild(el("div", "sm-ghost-body", "")).textContent = comment.comment;
843
+ pop.appendChild(el("div", "sm-ghost-body", "")).textContent = comment.text;
694
844
  lockHoverBoxToBbox(comment.resolvedTarget && comment.resolvedTarget.bbox);
695
845
  }
696
846
 
697
- // ---------- next-round attention ----------
698
- // Same trio as plan-overlay.js: a background tab cannot steal focus (browser
699
- // design), but it CAN fire a system notification whose click is allowed to
700
- // focus it, and flag itself in the tab bar until it is looked at. The
701
- // permission prompt only shows inside a user gesture, so it is requested at
702
- // the send-comments click; the "announce" wish survives the reload via
703
- // sessionStorage (the reload wipes this JS context).
704
- var ROUND_READY_FLAG = "slidemind-review-round-ready";
705
- var ATTENTION_TITLE = "● Preview ready — slidemind";
847
+ // The tab label while the agent works — the one attention string that is not
848
+ // the shared session's (it is set by this overlay's own mode switch).
706
849
  var WAITING_TITLE = "⏳ Agent revising slides — slidemind";
707
- var notifyPermPromise = null;
708
-
709
- function notifyPermission() {
710
- try {
711
- return "Notification" in window ? Notification.permission : "unsupported";
712
- } catch {
713
- return "unsupported";
714
- }
715
- }
716
- function requestNotifyPermission() {
717
- try {
718
- if (!("Notification" in window)) return Promise.resolve("unsupported");
719
- if (Notification.permission !== "default") return Promise.resolve(Notification.permission);
720
- return Promise.resolve(Notification.requestPermission());
721
- } catch {
722
- return Promise.resolve("unsupported");
723
- }
724
- }
725
- function markRoundReadyAndReload() {
726
- try {
727
- sessionStorage.setItem(ROUND_READY_FLAG, "1");
728
- } catch {
729
- /* storage blocked — the reload still lands, just without the announce */
730
- }
731
- location.reload();
732
- }
733
- function announceNewRound() {
734
- var flagged = false;
735
- try {
736
- flagged = sessionStorage.getItem(ROUND_READY_FLAG) === "1";
737
- if (flagged) sessionStorage.removeItem(ROUND_READY_FLAG);
738
- } catch {
739
- /* no storage — nothing to announce */
740
- }
741
- if (!flagged) return;
742
- try {
743
- window.focus(); // best-effort; most browsers ignore it for background tabs
744
- } catch {
745
- /* ignore */
746
- }
747
- if (!document.hidden) return; // user is already looking at it
748
- var origTitle = document.title;
749
- var restore = function () {
750
- document.title = origTitle;
751
- document.removeEventListener("visibilitychange", onVis);
752
- window.removeEventListener("focus", restore);
753
- };
754
- var onVis = function () {
755
- if (!document.hidden) restore();
756
- };
757
- document.title = ATTENTION_TITLE;
758
- document.addEventListener("visibilitychange", onVis);
759
- window.addEventListener("focus", restore);
760
- try {
761
- if (notifyPermission() === "granted") {
762
- var n = new Notification("slidemind — preview ready", {
763
- body: "The revised slides are ready for another look. Click to jump back to the tab.",
764
- tag: "slidemind-review-round",
765
- });
766
- n.onclick = function () {
767
- try {
768
- window.focus();
769
- } catch {
770
- /* ignore */
771
- }
772
- n.close();
773
- };
774
- }
775
- } catch {
776
- /* notification construction can throw on some platforms — title marker stands */
777
- }
778
- }
779
850
 
780
851
  // ---------- submit ----------
781
- function submit(decision) {
782
- if (submitted) return;
783
- submitted = true;
784
- // Ask for notification permission HERE — the send/approve click is a user
785
- // gesture, the only context where the browser will show the prompt.
786
- if (decision === "comments") notifyPermPromise = requestNotifyPermission();
787
- var comments = [];
788
- Object.keys(commentsBySlide).forEach(function (stem) {
789
- commentsBySlide[stem].forEach(function (c) {
790
- comments.push(c);
852
+ // A saved pin. WORKING → it queues locally (the agent is mid-round; it rides
853
+ // along on the next run). PARKED → it resolves the gate right now, carrying
854
+ // any queued stragglers from a flush that couldn't reach the server, and the
855
+ // tab enters working mode instead of veiling over.
856
+ function sendComment(c) {
857
+ ledger.push(c);
858
+ saveLedger();
859
+ renderAll();
860
+ if (working) return;
861
+ var batch = queuedComments();
862
+ // Ask for notification permission HERE — the save click is a user gesture,
863
+ // the only context where the browser will show the prompt.
864
+ SES.requestNotifyPermission();
865
+ fetch(CFG.endpoint, {
866
+ method: "POST",
867
+ headers: { "Content-Type": "application/json" },
868
+ body: JSON.stringify({ decision: "comments", comments: batch.map(SES.wire) }),
869
+ })
870
+ .then(function (res) {
871
+ if (res.status === 409) {
872
+ // The gate already drained a backlog this run — this round is stale.
873
+ enterWorking("Round superseded.");
874
+ return;
875
+ }
876
+ if (!res.ok) throw new Error("gate refused the decision");
877
+ batch.forEach(function (x) {
878
+ x.status = "sent";
879
+ });
880
+ saveLedger();
881
+ enterWorking();
882
+ })
883
+ .catch(function () {
884
+ // The command is gone (killed / timed out). The pin stays QUEUED and is
885
+ // delivered by the next run — the reviewer's words are never dropped.
886
+ enterWorking("Couldn't reach the terminal command.");
791
887
  });
792
- });
888
+ }
889
+ function approve() {
890
+ if (approved || working || queuedComments().length) return;
891
+ approved = true;
793
892
  fetch(CFG.endpoint, {
794
893
  method: "POST",
795
894
  headers: { "Content-Type": "application/json" },
796
- body: JSON.stringify({
797
- decision: decision,
798
- comments: decision === "comments" ? comments : [],
799
- }),
895
+ body: JSON.stringify({ decision: "approve", comments: [] }),
800
896
  })
801
- .then(function () {
802
- if (decision === "approve") {
803
- showDone("approve");
804
- requestTabClose();
805
- } else {
806
- showWaiting();
807
- startNextRoundPolling();
897
+ .then(function (res) {
898
+ if (res.status === 409) {
899
+ // A backlog drained this run: the render being approved is superseded.
900
+ approved = false;
901
+ enterWorking("Round superseded.");
902
+ return;
808
903
  }
904
+ showDone();
905
+ requestTabClose();
809
906
  })
810
907
  .catch(function () {
811
- submitted = false;
908
+ approved = false;
812
909
  alert("Could not reach the slidemind review server — is the terminal command still running?");
813
910
  });
814
911
  }
912
+ // ---------- working mode (the agent is fixing) ----------
913
+ // The tab neither veils over nor freezes: the stage stays browsable and
914
+ // pinnable, the ledger stays visible, and only Approve goes away. Heartbeat
915
+ // polling waits for the agent's next `slidemind review` run, which reloads
916
+ // this same tab with the new render.
917
+ function enterWorking(note) {
918
+ working = true;
919
+ workingNote = note || null;
920
+ document.title = WAITING_TITLE; // the tab label signals the state from the tab bar
921
+ closePopover();
922
+ renderAll();
923
+ startNextRoundPolling();
924
+ }
815
925
  function requestTabClose() {
816
926
  setTimeout(function () {
817
927
  try {
@@ -822,48 +932,17 @@
822
932
  }
823
933
  }, 120);
824
934
  }
825
- // After a comments round the tab stays open and polls the heartbeat so the
826
- // NEXT run (which binds the same deterministic port) reloads this tab instead
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.
935
+ // The shared heartbeat poll (gate-session.js) with this gate's two endings:
936
+ // a retired tab hands its backlog over FIRST — the fresh window that replaces
937
+ // it starts with an empty sessionStorage, so anything still queued here would
938
+ // otherwise be lost (the handover also drains the new run immediately).
833
939
  function startNextRoundPolling() {
834
- var startedAt = Date.now();
835
- var pollMs = CFG.pollMs || 1000;
836
- var ttl = CFG.pollTtlMs || 35 * 60 * 1000;
837
- var hb = CFG.heartbeat || "/__review/heartbeat";
838
- var timer = setInterval(function () {
839
- if (Date.now() - startedAt > ttl) {
840
- clearInterval(timer);
841
- showSessionEnded();
842
- return;
843
- }
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" })
848
- .then(function (r) { return r.ok ? r.json() : null; })
849
- .then(function (data) {
850
- // Act only on a genuine NEWER slidemind review round: shape-guard the
851
- // service marker (a foreign app may have taken the port) and require a
852
- // different nonce (same nonce = this run's own orphan — never self-reload).
853
- if (!data || data.service !== "slidemind-review" || !data.round) return;
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.
857
- clearInterval(timer);
858
- showSuperseded();
859
- return;
860
- }
861
- if (data.action === "wait") return; // grace window still deciding — keep polling
862
- clearInterval(timer);
863
- markRoundReadyAndReload();
864
- })
865
- .catch(function () { /* server down between rounds — keep polling */ });
866
- }, pollMs);
940
+ SES.startNextRoundPolling({
941
+ onEnded: showSessionEnded,
942
+ onSuperseded: function () {
943
+ flushBacklog().then(showSuperseded, showSuperseded);
944
+ },
945
+ });
867
946
  }
868
947
  // Shared full-screen veil (logo + wordmark + status + sub). Replaces any veil
869
948
  // already shown rather than stacking a second one, so a comments round's
@@ -882,22 +961,11 @@
882
961
  veil.appendChild(el("div", "sub" + (subPulse ? " sm-pulse" : ""), subText));
883
962
  document.body.appendChild(veil);
884
963
  }
964
+ // The veil is now ONLY for states where the review is over (approved, session
965
+ // expired, superseded). A comments round no longer veils: see enterWorking.
885
966
  function showDone() {
886
967
  showVeil(ICONS.check + "<span>Approved</span>", "Returned to your terminal.");
887
968
  }
888
- function showWaiting() {
889
- document.title = WAITING_TITLE; // the tab label signals the state from the tab bar
890
- showVeil(
891
- ICONS.check + "<span>Comments sent</span>",
892
- "The agent is revising the slides — this tab refreshes automatically when the next round is ready.",
893
- true,
894
- );
895
- (notifyPermPromise || Promise.resolve(notifyPermission())).then(function (perm) {
896
- if (perm !== "granted") return;
897
- var sub = document.querySelector(".sm-veil[data-sm-overlay] .sub");
898
- if (sub) sub.textContent += " You'll get a notification the moment it's ready.";
899
- });
900
- }
901
969
  function showSessionEnded() {
902
970
  showVeil("<span>Session ended</span>", "You can close this tab.");
903
971
  }
@@ -919,6 +987,7 @@
919
987
  currentStem = stem;
920
988
  closePopover();
921
989
  renderPins();
990
+ saveCtx(); // the slide the reviewer is on survives the round reload
922
991
  }
923
992
  }
924
993
 
@@ -951,6 +1020,11 @@
951
1020
  setTimeout(init, 50);
952
1021
  return;
953
1022
  }
1023
+ // Session first: it decides the MODE this page renders in (a queued backlog
1024
+ // means the agent is still working) and the round-transition state.
1025
+ var flagged = SES.consumeRoundFlag();
1026
+ restoreSession();
1027
+
954
1028
  injectAssets();
955
1029
  st.style.cursor = "crosshair";
956
1030
  // Slide play order, for the sidebar's jump-to-slide.
@@ -961,21 +1035,22 @@
961
1035
  buildPinLayer();
962
1036
  buildHoverBox();
963
1037
 
1038
+ // Pinnable in BOTH modes — a pin dropped while the agent is fixing queues
1039
+ // instead of resolving the gate. Only an approved session stops taking pins.
964
1040
  st.addEventListener("click", function (e) {
965
- if (submitted) return;
1041
+ if (approved) return;
966
1042
  if (e.target.closest("[data-sm-overlay]")) return;
967
1043
  var sr = st.getBoundingClientRect();
968
1044
  var x = (e.clientX - sr.left) / sr.width;
969
1045
  var y = (e.clientY - sr.top) / sr.height;
970
1046
  if (x < 0 || x > 1 || y < 0 || y > 1) return;
971
1047
  var hit = e.target.closest("[data-sm-loc]");
972
- var desc = describeTarget(hit);
973
- openDraft({ x: clamp01(x), y: clamp01(y) }, desc.region, desc.ancestors, e.clientX, e.clientY);
1048
+ openDraft({ x: clamp01(x), y: clamp01(y) }, describeTarget(hit), e.clientX, e.clientY);
974
1049
  });
975
1050
 
976
1051
  // Hover-box: outline the element under the cursor (the add-in's affordance).
977
1052
  st.addEventListener("pointermove", function (e) {
978
- if (submitted || locked) return;
1053
+ if (approved || locked) return;
979
1054
  if (e.target.closest("[data-sm-overlay]")) {
980
1055
  hideHoverBox();
981
1056
  return;
@@ -1002,8 +1077,21 @@
1002
1077
  });
1003
1078
  }
1004
1079
  syncSlideFromNav();
1080
+ // Round transition: come back to the slide the reviewer was reading.
1081
+ if (pendingStem && pendingStem !== currentStem) goToStem(pendingStem);
1005
1082
  applyStageFit();
1006
- announceNewRound(); // notification + title marker when this load IS the next round
1083
+ renderAll();
1084
+
1085
+ if (working) {
1086
+ // Drain round: hand the backlog over, stay in working mode, stay silent —
1087
+ // the reviewer already knows the agent is mid-fix.
1088
+ document.title = WAITING_TITLE;
1089
+ startNextRoundPolling();
1090
+ flushBacklog().then(renderAll); // rows flip queued → sent
1091
+ } else {
1092
+ // Parked round: the agent is waiting on the reviewer — announce it.
1093
+ SES.announceNewRound(flagged);
1094
+ }
1007
1095
  }
1008
1096
 
1009
1097
  if (document.readyState === "loading") {