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.
- package/CLAUDE.md +10 -7
- package/README.md +20 -7
- package/bin/slidemind.mjs +393 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +68 -16
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -0
- package/src/builders/build.mjs +83 -11
- package/src/builders/gate-server.mjs +364 -0
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +1778 -0
- package/src/builders/plan.mjs +696 -0
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +191 -13
- package/src/builders/review.mjs +196 -68
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
|
@@ -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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|