slidemind 0.1.7 → 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.
@@ -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
+ }
@@ -176,17 +176,52 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
176
176
  // plan.md frontmatter
177
177
  // ---------------------------------------------------------------------------
178
178
 
179
+ // Split a plan.md into its YAML-ish frontmatter fence and the markdown body,
180
+ // LOSSLESSLY: `frontmatterRaw + body === text` byte-for-byte for every input.
181
+ // Same fence rule as the frontmatter parser: frontmatter exists iff the text
182
+ // starts with "---" and there is a later "\n---". `frontmatterRaw` runs through
183
+ // the END of the closing fence line INCLUDING its trailing newline (or the whole
184
+ // text, with an empty body, when the closing fence is the last line and carries
185
+ // no trailing newline). `bodyStartLine` is the 1-based line the body begins on
186
+ // (= count of "\n" in frontmatterRaw + 1). With no frontmatter the whole text is
187
+ // the body and `bodyStartLine` is 1.
188
+ export function splitPlanFrontmatter(text) {
189
+ const s = String(text);
190
+ if (!s.startsWith("---")) {
191
+ return { frontmatterRaw: "", body: s, bodyStartLine: 1 };
192
+ }
193
+ const end = s.indexOf("\n---", 3);
194
+ if (end < 0) {
195
+ return { frontmatterRaw: "", body: s, bodyStartLine: 1 };
196
+ }
197
+ // The closing fence line starts at end+1 (the first "-"); it ends at the next
198
+ // newline. frontmatterRaw includes that newline so the body starts clean.
199
+ const nlAfterFence = s.indexOf("\n", end + 1);
200
+ let frontmatterRaw;
201
+ let body;
202
+ if (nlAfterFence < 0) {
203
+ frontmatterRaw = s; // closing fence at EOF, no trailing newline
204
+ body = "";
205
+ } else {
206
+ frontmatterRaw = s.slice(0, nlAfterFence + 1);
207
+ body = s.slice(nlAfterFence + 1);
208
+ }
209
+ const bodyStartLine = (frontmatterRaw.match(/\n/g) || []).length + 1;
210
+ return { frontmatterRaw, body, bodyStartLine };
211
+ }
212
+
179
213
  function readPlanFrontmatter(deckDir) {
180
214
  if (!deckDir) return {};
181
215
  const file = path.join(deckDir, "plan.md");
182
216
  if (!fs.existsSync(file)) return {};
183
217
  const text = fs.readFileSync(file, "utf8");
184
- if (!text.startsWith("---")) return {};
185
- const end = text.indexOf("\n---", 3);
186
- if (end < 0) return {};
187
- const body = text.slice(3, end);
218
+ const { frontmatterRaw } = splitPlanFrontmatter(text);
219
+ if (!frontmatterRaw) return {}; // no frontmatter fence → {}, exactly as before
220
+ // Parse key: value fields out of the fence block. The two "---" fence lines
221
+ // never match the field regex, so scanning the whole raw block is equivalent
222
+ // to scanning strictly between the fences.
188
223
  const out = {};
189
- for (const line of body.split("\n")) {
224
+ for (const line of frontmatterRaw.split("\n")) {
190
225
  const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.+?)\s*$/);
191
226
  if (m) out[m[1]] = stripQuotes(m[2]);
192
227
  }
@@ -120,6 +120,7 @@
120
120
  "@keyframes sm-pin-in{from{transform:translate(-50%,-50%) scale(0)}to{transform:translate(-50%,-50%) scale(1)}}" +
121
121
  "@keyframes sm-veil-in{from{opacity:0}to{opacity:1}}" +
122
122
  "@keyframes sm-rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}" +
123
+ "@keyframes sm-pulse{0%,100%{opacity:.45}50%{opacity:.85}}" +
123
124
  ":root{--sm-bg:var(--deck-chrome-bg,#111113);--sm-surface:var(--deck-chrome-bg-2,#161618);" +
124
125
  "--sm-border:var(--deck-chrome-border,#2b2b30);--sm-fg:var(--deck-chrome-fg,#ededed);" +
125
126
  "--sm-dim:var(--deck-chrome-dim,#9a9aa0);--sm-accent:#a9c2ff;--sm-success:#2ecc71;--sm-danger:#f87171;" +
@@ -143,6 +144,9 @@
143
144
  ".sm-meta{display:flex;flex-direction:column;gap:2px;}" +
144
145
  ".sm-meta .t{font-weight:600;font-size:13px;}" +
145
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;}" +
146
150
  ".sm-bar~.deck__rail,.deck__rail{top:var(--deck-chrome-h,62px);}" +
147
151
  ".sm-grow{flex:1;}" +
148
152
  ".sm-count{font-size:12px;color:var(--sm-dim);font-variant-numeric:tabular-nums;}" +
@@ -195,6 +199,7 @@
195
199
  ".sm-veil .status{display:flex;align-items:center;gap:10px;font-size:18px;font-weight:600;animation:sm-rise .6s .12s cubic-bezier(.2,.7,.2,1) both;}" +
196
200
  ".sm-veil .status svg{width:24px;height:24px;color:var(--sm-success);}" +
197
201
  ".sm-veil .sub{color:var(--sm-dim);font-size:13.5px;animation:sm-rise .6s .18s cubic-bezier(.2,.7,.2,1) both;}" +
202
+ ".sm-veil .sub.sm-pulse{animation:sm-rise .6s .18s cubic-bezier(.2,.7,.2,1) both,sm-pulse 2s .9s ease-in-out infinite;}" +
198
203
  /* hover box */
199
204
  ".sm-hover{position:fixed;z-index:2147483450;pointer-events:none;border-radius:5px;border:2px solid var(--sm-accent);" +
200
205
  "box-shadow:0 0 0 1px rgba(0,0,0,.3),0 6px 22px rgba(0,0,0,.28);opacity:0;" +
@@ -317,7 +322,7 @@
317
322
  }
318
323
 
319
324
  // ---------- chrome (top bar) ----------
320
- var bar, sendBtn, countLabel;
325
+ var bar, sendBtn, approveBtn, countLabel;
321
326
  function buildChrome() {
322
327
  bar = el("div", "sm-o sm-bar");
323
328
  bar.setAttribute("data-sm-overlay", "1");
@@ -333,6 +338,13 @@
333
338
  meta.appendChild(el("div", "h", "Click any element to comment · ← → to switch slide"));
334
339
  bar.appendChild(meta);
335
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
+
336
348
  bar.appendChild(el("div", "sm-grow"));
337
349
 
338
350
  countLabel = el("div", "sm-count");
@@ -345,8 +357,9 @@
345
357
  };
346
358
  bar.appendChild(sendBtn);
347
359
 
348
- var approveBtn = mkButton("Approve & continue", "primary", "check");
360
+ approveBtn = mkButton("Approve & continue", "primary", "check");
349
361
  approveBtn.onclick = function () {
362
+ if (totalComments() > 0) return;
350
363
  submit("approve");
351
364
  };
352
365
  bar.appendChild(approveBtn);
@@ -360,6 +373,11 @@
360
373
  sendBtn.querySelector("span").textContent =
361
374
  n > 0 ? "Send " + n + " comment" + (n === 1 ? "" : "s") : "Send comments";
362
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.";
363
381
  }
364
382
 
365
383
  // ---------- hover box (mirrors the add-in's hit-region-overlay) ----------
@@ -676,10 +694,96 @@
676
694
  lockHoverBoxToBbox(comment.resolvedTarget && comment.resolvedTarget.bbox);
677
695
  }
678
696
 
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";
706
+ 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
+
679
780
  // ---------- submit ----------
680
781
  function submit(decision) {
681
782
  if (submitted) return;
682
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();
683
787
  var comments = [];
684
788
  Object.keys(commentsBySlide).forEach(function (stem) {
685
789
  commentsBySlide[stem].forEach(function (c) {
@@ -695,8 +799,13 @@
695
799
  }),
696
800
  })
697
801
  .then(function () {
698
- showDone(decision);
699
- requestTabClose();
802
+ if (decision === "approve") {
803
+ showDone("approve");
804
+ requestTabClose();
805
+ } else {
806
+ showWaiting();
807
+ startNextRoundPolling();
808
+ }
700
809
  })
701
810
  .catch(function () {
702
811
  submitted = false;
@@ -713,24 +822,92 @@
713
822
  }
714
823
  }, 120);
715
824
  }
716
- function showDone(decision) {
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.
833
+ 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);
867
+ }
868
+ // Shared full-screen veil (logo + wordmark + status + sub). Replaces any veil
869
+ // already shown rather than stacking a second one, so a comments round's
870
+ // "waiting" veil can be swapped for "session ended" in place.
871
+ function showVeil(statusHtml, subText, subPulse) {
717
872
  closePopover();
718
873
  if (pinLayer) pinLayer.style.display = "none";
719
874
  if (bar) bar.style.display = "none";
875
+ var existing = document.querySelector(".sm-veil[data-sm-overlay]");
876
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
720
877
  var veil = el("div", "sm-o sm-veil");
721
878
  veil.setAttribute("data-sm-overlay", "1");
722
879
  veil.appendChild(logoNode("sm-logo", "-v"));
723
880
  veil.appendChild(el("div", "word", wordmarkHtml()));
724
- veil.appendChild(
725
- el(
726
- "div",
727
- "status",
728
- ICONS.check + "<span>" + (decision === "approve" ? "Approved" : "Comments sent") + "</span>",
729
- ),
730
- );
731
- veil.appendChild(el("div", "sub", "Returned to your terminal."));
881
+ veil.appendChild(el("div", "status", statusHtml));
882
+ veil.appendChild(el("div", "sub" + (subPulse ? " sm-pulse" : ""), subText));
732
883
  document.body.appendChild(veil);
733
884
  }
885
+ function showDone() {
886
+ showVeil(ICONS.check + "<span>Approved</span>", "Returned to your terminal.");
887
+ }
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
+ function showSessionEnded() {
902
+ showVeil("<span>Session ended</span>", "You can close this tab.");
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
+ }
734
911
 
735
912
  // ---------- slide tracking ----------
736
913
  function syncSlideFromNav() {
@@ -826,6 +1003,7 @@
826
1003
  }
827
1004
  syncSlideFromNav();
828
1005
  applyStageFit();
1006
+ announceNewRound(); // notification + title marker when this load IS the next round
829
1007
  }
830
1008
 
831
1009
  if (document.readyState === "loading") {