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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +65 -15
- package/README.md +36 -2
- package/bin/slidemind.mjs +1609 -225
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +47 -19
- package/skills/search/SKILL.md +26 -18
- package/src/bridge/build-and-push.mjs +431 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +1214 -107
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/engine-download.mjs +61 -8
- package/src/bridge/graph-auth.mjs +859 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +181 -135
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
|
@@ -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]')
|
|
11
|
-
//
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
323
|
+
return commentsForStem(currentStem);
|
|
244
324
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
288
|
-
//
|
|
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
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
approveBtn.
|
|
380
|
-
|
|
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
|
-
// ----------
|
|
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 +
|
|
448
|
-
y: sr.top +
|
|
569
|
+
x: sr.left + fx(c) * sr.width,
|
|
570
|
+
y: sr.top + fy(c) * sr.height,
|
|
449
571
|
};
|
|
450
572
|
}
|
|
451
|
-
|
|
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 (
|
|
454
|
-
else
|
|
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
|
|
583
|
+
function sideRow(stem, c, num) {
|
|
457
584
|
var r = el("div", "sm-side-row");
|
|
458
|
-
var chip = el("div", "sm-side-chip" + (
|
|
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.
|
|
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
|
|
598
|
+
openCommentAt(c);
|
|
469
599
|
};
|
|
470
600
|
return r;
|
|
471
601
|
}
|
|
472
602
|
function updateSidebar() {
|
|
473
|
-
|
|
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 +
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
508
|
-
grp.appendChild(sideRow(stem, c, i + 1
|
|
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
|
|
690
|
+
pinLayer.appendChild(makePin(c, sr, i + 1));
|
|
555
691
|
});
|
|
556
692
|
}
|
|
557
|
-
function
|
|
558
|
-
|
|
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.
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
(
|
|
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
|
-
|
|
635
|
-
|
|
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.
|
|
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
|
|
661
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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.
|
|
819
|
+
comment.text = text;
|
|
674
820
|
closePopover();
|
|
675
|
-
|
|
676
|
-
|
|
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(
|
|
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.
|
|
843
|
+
pop.appendChild(el("div", "sm-ghost-body", "")).textContent = comment.text;
|
|
694
844
|
lockHoverBoxToBbox(comment.resolvedTarget && comment.resolvedTarget.bbox);
|
|
695
845
|
}
|
|
696
846
|
|
|
697
|
-
//
|
|
698
|
-
//
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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 (
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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") {
|