slidemind 0.1.6 → 0.1.8

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,747 @@
1
+ // Plan sign-off overlay — the CLI's plan-review gate, styled as the same
2
+ // branded slidemind "app" as the preview-review overlay (review-overlay.js).
3
+ // Dormant unless `window.__SLIDEMIND_PLAN_REVIEW__` is set (only the
4
+ // `slidemind plan` local server injects it), and dormant inside an iframe /
5
+ // thumbnail render, so nothing runs on an ordinary page.
6
+ //
7
+ // The gate is editor-first: a plain markdown editor (the raw plan.md body) on
8
+ // the left, a live rendered preview on the right — re-rendered client-side via
9
+ // the same dependency-free renderer the server used (`plan-md.mjs`, shipped
10
+ // inline as window.__SLIDEMIND_RENDER_MD__). The preview pane is collapsible
11
+ // so users who just want the editor can hide it.
12
+ //
13
+ // The decision lives in ONE always-visible chat-style composer bar pinned at
14
+ // the bottom (no per-block pins, nothing behind a pill): a chat input ("tell
15
+ // the agent what to change…"), a round send button, and — fused to its right —
16
+ // the Approve button, whose sub-label says what approving means: the agent
17
+ // builds the slides next. The previous round's feedback shows as a read-only
18
+ // note above the input.
19
+ // - send → decision "comments" with the overall comment. The tab does
20
+ // NOT veil over: the workspace stays readable (editor locks,
21
+ // read-only) and the composer bar becomes a spinner + wait
22
+ // note while the heartbeat polling waits for the agent's next
23
+ // `slidemind plan` round, which reloads this same tab.
24
+ // - Approve → decision "approve" (unsent feedback arm-confirms first),
25
+ // then the full-screen brand animation — logo + drifting
26
+ // wordmark + "the agent is building your slides" — before the
27
+ // tab closes itself.
28
+ // Both POST {decision, comment, editedPlan} to the local server so the
29
+ // `slidemind plan` command unblocks.
30
+ //
31
+ // Branding is lifted verbatim from review-overlay.js: the embossed "raised
32
+ // slide" logo (inline-SVG fallback when no per-brand orb PNG is bundled), the
33
+ // drifting "slide"/"mind" wordmark, Poppins/Urbanist type, Lucide icons, and
34
+ // the neutral dark product palette.
35
+
36
+ (function () {
37
+ var CFG = window.__SLIDEMIND_PLAN_REVIEW__;
38
+ if (!CFG) return;
39
+ // Dormant inside an iframe / thumbnail render (mirrors review-overlay).
40
+ if (window.top !== window.self) return;
41
+ if (new URLSearchParams(location.search).get("thumb") === "1") return;
42
+
43
+ var PREVIEW_PREF_KEY = "slidemind-plan-preview";
44
+ var RENDER_DEBOUNCE_MS = 150;
45
+ var APPROVE_CLOSE_DELAY_MS = 2400; // long enough to read the build animation
46
+ var ROUND_READY_FLAG = "slidemind-plan-round-ready"; // sessionStorage marker across the reload
47
+ var ATTENTION_TITLE = "● Plan ready — slidemind";
48
+ var WAITING_TITLE = "⏳ Agent revising — slidemind";
49
+
50
+ // ---------- state ----------
51
+ var submitted = false;
52
+ var approveArmed = false; // inline "discard unsent feedback?" confirm state
53
+ var previewOpen = true;
54
+ var baseline = CFG.planBody || ""; // the served plan body; edits diff against it
55
+ var renderTimer = null;
56
+
57
+ var bar, wsEl, edPane, previewPane, editorEl, commentEl, composerEl;
58
+ var editedBadge, approveBtn, sendBtn, previewBtn;
59
+ var waitTextEl = null;
60
+ var notifyPermPromise = null; // resolved Notification permission, asked at the send gesture
61
+
62
+ // ---------- brand assets (identical to review-overlay.js) ----------
63
+ var LOGO_SVG =
64
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="160 160 960 540" preserveAspectRatio="none">' +
65
+ "<defs>" +
66
+ '<linearGradient id="rimTop" gradientUnits="userSpaceOnUse" x1="160" y1="0" x2="1120" y2="0">' +
67
+ '<stop offset="0%" stop-color="#d0e4ff" stop-opacity="0.85"/>' +
68
+ '<stop offset="35%" stop-color="#fff" stop-opacity="0.5"/>' +
69
+ '<stop offset="100%" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
70
+ '<linearGradient id="rimLeft" gradientUnits="userSpaceOnUse" x1="0" y1="160" x2="0" y2="700">' +
71
+ '<stop offset="0%" stop-color="#d0e4ff" stop-opacity="0.6"/>' +
72
+ '<stop offset="40%" stop-color="#fff" stop-opacity="0.28"/>' +
73
+ '<stop offset="100%" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
74
+ '<linearGradient id="rimBottom" gradientUnits="userSpaceOnUse" x1="1120" y1="0" x2="160" y2="0">' +
75
+ '<stop offset="0%" stop-color="#000" stop-opacity="0.55"/>' +
76
+ '<stop offset="60%" stop-color="#000" stop-opacity="0.2"/>' +
77
+ '<stop offset="100%" stop-color="#000" stop-opacity="0"/></linearGradient>' +
78
+ '<linearGradient id="rimRight" gradientUnits="userSpaceOnUse" x1="0" y1="700" x2="0" y2="160">' +
79
+ '<stop offset="0%" stop-color="#000" stop-opacity="0.45"/>' +
80
+ '<stop offset="60%" stop-color="#000" stop-opacity="0.16"/>' +
81
+ '<stop offset="100%" stop-color="#000" stop-opacity="0"/></linearGradient>' +
82
+ '<linearGradient id="faceFill" gradientUnits="userSpaceOnUse" x1="160" y1="160" x2="1120" y2="700">' +
83
+ '<stop offset="0%" stop-color="#fff" stop-opacity="0.08"/>' +
84
+ '<stop offset="50%" stop-color="#808080" stop-opacity="0.02"/>' +
85
+ '<stop offset="100%" stop-color="#000" stop-opacity="0.10"/></linearGradient>' +
86
+ "</defs>" +
87
+ '<rect x="160" y="160" width="960" height="540" rx="40" ry="40" fill="url(#faceFill)"/>' +
88
+ '<path d="M 160,200 A 40,40 0 0 1 200,160 L 1080,160 A 40,40 0 0 1 1120,200" fill="none" stroke="url(#rimTop)" stroke-width="3" stroke-linecap="round"/>' +
89
+ '<path d="M 160,200 L 160,660 A 40,40 0 0 0 200,700" fill="none" stroke="url(#rimLeft)" stroke-width="2.4" stroke-linecap="round"/>' +
90
+ '<path d="M 1120,660 A 40,40 0 0 1 1080,700 L 200,700 A 40,40 0 0 1 160,660" fill="none" stroke="url(#rimBottom)" stroke-width="3" stroke-linecap="round"/>' +
91
+ '<path d="M 1120,660 L 1120,200 A 40,40 0 0 0 1080,160" fill="none" stroke="url(#rimRight)" stroke-width="2.4" stroke-linecap="round"/>' +
92
+ '<rect x="160" y="160" width="960" height="540" rx="40" ry="40" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.4"/>' +
93
+ "</svg>";
94
+
95
+ // Give a copy of the logo unique gradient ids so two instances (header + done
96
+ // veil) can coexist without id collisions.
97
+ function logoWithIds(suffix) {
98
+ return LOGO_SVG.replace(/id="([^"]+)"/g, 'id="$1' + suffix + '"').replace(
99
+ /url\(#([^)]+)\)/g,
100
+ "url(#$1" + suffix + ")",
101
+ );
102
+ }
103
+
104
+ // Lucide icons (MIT) — the same family review-overlay uses.
105
+ var ICONS = {
106
+ check:
107
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
108
+ panel:
109
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>',
110
+ ghost:
111
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11a9 9 0 0 1 18 0v10l-3-2-3 2-3-2-3 2-3-2-3 2Z"/><path d="M9 11h.01"/><path d="M15 11h.01"/></svg>',
112
+ send:
113
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>',
114
+ };
115
+
116
+ // ---------- styles (chrome copied from review-overlay.js; pins/popover/
117
+ // sidebar rules dropped, editor/preview/dock rules added) ----------
118
+ var CSS =
119
+ "@keyframes sm-drift-a{0%,100%{transform:translateX(-5px)}50%{transform:translateX(5px)}}" +
120
+ "@keyframes sm-drift-b{0%,100%{transform:translateX(5px)}50%{transform:translateX(-5px)}}" +
121
+ "@keyframes sm-bar-in{from{transform:translateY(-100%);opacity:0}to{transform:none;opacity:1}}" +
122
+ "@keyframes sm-veil-in{from{opacity:0}to{opacity:1}}" +
123
+ "@keyframes sm-rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}" +
124
+ "@keyframes sm-pulse{0%,100%{opacity:.45}50%{opacity:.85}}" +
125
+ ":root{--sm-bg:var(--deck-chrome-bg,#111113);--sm-surface:var(--deck-chrome-bg-2,#161618);" +
126
+ "--sm-border:var(--deck-chrome-border,#2b2b30);--sm-fg:var(--deck-chrome-fg,#ededed);" +
127
+ "--sm-dim:var(--deck-chrome-dim,#9a9aa0);--sm-accent:#a9c2ff;--sm-success:#2ecc71;--sm-danger:#f87171;" +
128
+ "--sm-warn:#f6c454;--sm-font:'Poppins',ui-sans-serif,system-ui,sans-serif;--sm-word:'Urbanist',var(--sm-font);}" +
129
+ ".sm-o,.sm-o *{box-sizing:border-box;}" +
130
+ /* header */
131
+ ".sm-bar{position:fixed;top:0;left:0;right:0;z-index:2147483600;display:flex;align-items:center;" +
132
+ "gap:16px;height:var(--deck-chrome-h,62px);padding:0 18px;color:var(--sm-fg);font-family:var(--sm-font);font-size:13px;" +
133
+ "background:var(--sm-bg);border-bottom:1px solid var(--sm-border);" +
134
+ "user-select:none;animation:sm-bar-in .5s cubic-bezier(.2,.7,.2,1);}" +
135
+ ".sm-brand{display:flex;align-items:center;gap:11px;}" +
136
+ ".sm-logo{position:relative;width:48px;height:27px;border-radius:5px;overflow:hidden;flex-shrink:0;background:#0a0c11;box-shadow:0 1px 3px rgba(0,0,0,.5);}" +
137
+ ".sm-logo-svg{position:absolute;inset:0;z-index:1;}.sm-logo-svg svg{width:100%;height:100%;display:block;}" +
138
+ ".sm-logo-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;}" +
139
+ ".sm-word{display:flex;flex-direction:column;line-height:.98;font-family:var(--sm-word);" +
140
+ "font-weight:300;font-size:13px;letter-spacing:.46em;color:var(--sm-fg);}" +
141
+ ".sm-word b{font-weight:300;display:block;will-change:transform;}" +
142
+ ".sm-word .top{animation:sm-drift-a 20s ease-in-out infinite;}" +
143
+ ".sm-word .bot{margin-left:.18em;animation:sm-drift-b 20s ease-in-out infinite;}" +
144
+ ".sm-sep{width:1px;height:28px;background:var(--sm-border);}" +
145
+ ".sm-meta{display:flex;flex-direction:column;gap:2px;}" +
146
+ ".sm-meta .t{font-weight:600;font-size:13px;display:flex;align-items:center;gap:8px;}" +
147
+ ".sm-meta .h{font-size:11.5px;color:var(--sm-dim);}" +
148
+ ".sm-edited{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--sm-warn);" +
149
+ "border:1px solid rgba(246,196,84,.4);border-radius:999px;padding:1px 7px;}" +
150
+ ".sm-grow{flex:1;}" +
151
+ /* buttons */
152
+ ".sm-btn{display:inline-flex;align-items:center;gap:7px;border:1px solid transparent;border-radius:9px;" +
153
+ "padding:8px 14px;font-family:var(--sm-font);font-weight:600;font-size:12.5px;cursor:pointer;" +
154
+ "transition:transform .14s ease,background .14s ease,box-shadow .14s ease,border-color .14s ease;white-space:nowrap;}" +
155
+ ".sm-btn svg{width:15px;height:15px;flex-shrink:0;}" +
156
+ ".sm-btn-primary{background:#f4f4f5;color:#0f0f0f;}" +
157
+ ".sm-btn-primary:hover{background:#fff;transform:translateY(-1px);box-shadow:0 6px 18px rgba(0,0,0,.45);}" +
158
+ ".sm-btn-primary svg{color:var(--sm-success);}" +
159
+ ".sm-btn-primary.is-armed{background:#f6c454;color:#241a00;}" +
160
+ ".sm-btn-primary.is-armed svg{color:#241a00;}" +
161
+ ".sm-btn-ghost{background:transparent;border-color:var(--sm-border);color:var(--sm-fg);}" +
162
+ ".sm-btn-ghost:hover{background:rgba(255,255,255,.06);border-color:#3a3a40;}" +
163
+ ".sm-btn-ghost.is-on{background:rgba(169,194,255,.14);border-color:var(--sm-accent);color:#dbe6ff;}" +
164
+ ".sm-btn.is-off{opacity:.4;cursor:not-allowed;pointer-events:none;}" +
165
+ /* workspace: editor + preview panes, comment dock */
166
+ ".sm-ws{position:fixed;left:0;right:0;top:var(--deck-chrome-h,62px);bottom:0;z-index:2147483540;" +
167
+ "display:flex;flex-direction:column;background:var(--sm-bg);}" +
168
+ ".sm-panes{flex:1;display:flex;min-height:0;}" +
169
+ ".sm-ed{flex:1;min-width:0;display:flex;flex-direction:column;border-right:1px solid var(--sm-border);}" +
170
+ ".sm-panes.preview-hidden .sm-ed{border-right:0;}" +
171
+ ".sm-ed-ta{flex:1;width:100%;resize:none;border:0;outline:none;background:#0c0c0e;color:#e8e8ea;" +
172
+ "padding:22px 24px;font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;tab-size:2;}" +
173
+ ".sm-prev{flex:1;min-width:0;overflow-y:auto;background:#0e0e10;}" +
174
+ ".sm-panes.preview-hidden .sm-prev{display:none;}" +
175
+ ".sm-prev #plan-root{padding:28px 30px 90px;}" +
176
+ /* chat composer bar — always visible; owns the whole decision */
177
+ ".sm-composer{flex-shrink:0;display:flex;flex-direction:column;gap:8px;padding:11px 16px;" +
178
+ "border-top:1px solid var(--sm-border);background:var(--sm-surface);font-family:var(--sm-font);}" +
179
+ ".sm-prev-note{display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--sm-dim);" +
180
+ "line-height:1.5;padding:7px 10px;border:1px dashed #3a3a40;border-radius:9px;background:rgba(255,255,255,.02);}" +
181
+ ".sm-prev-note svg{width:14px;height:14px;flex-shrink:0;margin-top:2px;color:#71717a;}" +
182
+ ".sm-prev-note .lbl{font-weight:600;color:#a1a1aa;white-space:nowrap;}" +
183
+ ".sm-prev-note .txt{white-space:pre-wrap;word-break:break-word;color:#b9b9be;}" +
184
+ ".sm-composer-row{display:flex;align-items:flex-end;gap:10px;}" +
185
+ ".sm-chat-ta{flex:1;min-height:40px;max-height:150px;resize:none;background:#0c0c0e;color:var(--sm-fg);" +
186
+ "border:1px solid var(--sm-border);border-radius:14px;padding:10px 15px;font:13px/1.5 var(--sm-font);" +
187
+ "outline:none;transition:border-color .15s;}" +
188
+ ".sm-chat-ta:focus{border-color:var(--sm-accent);}.sm-chat-ta::placeholder{color:#6b6b72;}" +
189
+ ".sm-send{width:38px;height:38px;border-radius:50%;border:0;cursor:pointer;flex-shrink:0;" +
190
+ "display:flex;align-items:center;justify-content:center;background:var(--sm-accent);color:#0f1220;" +
191
+ "transition:transform .14s ease,box-shadow .14s ease,opacity .14s ease;}" +
192
+ ".sm-send svg{width:16px;height:16px;margin:1px 1px 0 0;}" +
193
+ ".sm-send:hover{transform:translateY(-1px) scale(1.05);box-shadow:0 8px 22px rgba(169,194,255,.35);}" +
194
+ ".sm-send.is-off{opacity:.35;cursor:not-allowed;pointer-events:none;}" +
195
+ ".sm-composer-sep{width:1px;align-self:stretch;background:var(--sm-border);}" +
196
+ /* approve — the sub-label announces the consequence: the agent builds next */
197
+ ".sm-approve{display:inline-flex;flex-direction:column;align-items:center;gap:1px;border:0;" +
198
+ "border-radius:12px;padding:7px 16px;background:#f4f4f5;color:#0f0f0f;cursor:pointer;font-family:var(--sm-font);" +
199
+ "transition:transform .14s ease,background .14s ease,box-shadow .14s ease;white-space:nowrap;}" +
200
+ ".sm-approve:hover{background:#fff;transform:translateY(-1px);box-shadow:0 6px 18px rgba(0,0,0,.45);}" +
201
+ ".sm-approve .main{display:flex;align-items:center;gap:7px;font-weight:600;font-size:12.5px;}" +
202
+ ".sm-approve .main svg{width:15px;height:15px;color:var(--sm-success);}" +
203
+ ".sm-approve .sub{font-size:9.5px;font-weight:500;color:#61616a;}" +
204
+ ".sm-approve.is-armed{background:#f6c454;color:#241a00;}" +
205
+ ".sm-approve.is-armed .main svg{color:#241a00;}" +
206
+ /* waiting state — feedback sent, the agent is revising; tab stays readable */
207
+ "@keyframes sm-rotate{to{transform:rotate(360deg)}}" +
208
+ ".sm-wait{display:flex;align-items:center;gap:12px;padding:7px 2px;font-size:12.5px;" +
209
+ "color:var(--sm-dim);line-height:1.5;}" +
210
+ ".sm-wait b{color:var(--sm-fg);font-weight:600;}" +
211
+ ".sm-spin{width:17px;height:17px;border-radius:50%;border:2px solid var(--sm-border);" +
212
+ "border-top-color:var(--sm-accent);animation:sm-rotate .9s linear infinite;flex-shrink:0;}" +
213
+ ".sm-ws.is-waiting .sm-ed-ta{opacity:.55;}" +
214
+ /* done veil */
215
+ ".sm-veil{position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;align-items:center;" +
216
+ "justify-content:center;gap:30px;font-family:var(--sm-font);color:var(--sm-fg);" +
217
+ "background:radial-gradient(ellipse at center,rgba(16,16,18,.93),rgba(5,5,7,.98));" +
218
+ "-webkit-backdrop-filter:blur(7px);backdrop-filter:blur(7px);animation:sm-veil-in .4s ease;}" +
219
+ ".sm-veil .sm-logo{width:188px;height:106px;border-radius:14px;animation:sm-rise .6s cubic-bezier(.2,.7,.2,1) both;}" +
220
+ ".sm-veil .word{display:flex;flex-direction:column;gap:6px;font-family:var(--sm-word);font-weight:300;" +
221
+ "font-size:36px;letter-spacing:.6em;text-align:center;animation:sm-rise .6s .06s cubic-bezier(.2,.7,.2,1) both;}" +
222
+ ".sm-veil .word b{font-weight:300;}" +
223
+ ".sm-veil .word .top{animation:sm-drift-a 18s ease-in-out infinite;}" +
224
+ ".sm-veil .word .bot{margin-left:.3em;animation:sm-drift-b 18s ease-in-out infinite;}" +
225
+ ".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;}" +
226
+ ".sm-veil .status svg{width:24px;height:24px;color:var(--sm-success);}" +
227
+ ".sm-veil .sub{color:var(--sm-dim);font-size:13.5px;animation:sm-rise .6s .18s cubic-bezier(.2,.7,.2,1) both;}" +
228
+ ".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;}";
229
+
230
+ // ---------- helpers ----------
231
+ function el(tag, cls, html) {
232
+ var e = document.createElement(tag);
233
+ if (cls) e.className = cls;
234
+ if (html != null) e.innerHTML = html;
235
+ return e;
236
+ }
237
+ function planRoot() {
238
+ return document.getElementById("plan-root");
239
+ }
240
+ function wordmarkHtml() {
241
+ return '<b class="top">slide</b><b class="bot">mind</b>';
242
+ }
243
+ // Genuine path: the add-in's per-brand orb PNG (CFG.logoMark). Fallback: the
244
+ // inline embossed slide glyph — no CLI-side orb re-creation.
245
+ function logoNode(cls, idSuffix) {
246
+ var box = el("div", cls);
247
+ if (CFG.logoMark) {
248
+ var img = el("img", "sm-logo-img");
249
+ img.src = CFG.logoMark;
250
+ img.alt = "slidemind";
251
+ box.appendChild(img);
252
+ return box;
253
+ }
254
+ box.appendChild(el("div", "sm-logo-svg", idSuffix ? logoWithIds(idSuffix) : LOGO_SVG));
255
+ return box;
256
+ }
257
+ function mkButton(label, variant, iconName) {
258
+ var b = el("button", "sm-btn sm-btn-" + variant);
259
+ b.innerHTML = (iconName ? ICONS[iconName] : "") + "<span>" + label + "</span>";
260
+ return b;
261
+ }
262
+ function btnLabel(b, text) {
263
+ var s = b.querySelector("span");
264
+ if (s) s.textContent = text;
265
+ }
266
+ function planWasEdited() {
267
+ return !!editorEl && editorEl.value !== baseline;
268
+ }
269
+ function commentText() {
270
+ return commentEl ? commentEl.value.trim() : "";
271
+ }
272
+
273
+ // ---------- chrome (top bar) ----------
274
+ function buildChrome() {
275
+ bar = el("div", "sm-o sm-bar");
276
+ bar.setAttribute("data-sm-overlay", "1");
277
+
278
+ var brand = el("div", "sm-brand");
279
+ brand.appendChild(logoNode("sm-logo"));
280
+ brand.appendChild(el("div", "sm-word", wordmarkHtml()));
281
+ bar.appendChild(brand);
282
+ bar.appendChild(el("div", "sm-sep"));
283
+
284
+ var meta = el("div", "sm-meta");
285
+ var title = el("div", "t");
286
+ title.appendChild(document.createTextNode("Plan review"));
287
+ editedBadge = el("span", "sm-edited", "edited");
288
+ editedBadge.style.display = "none";
289
+ title.appendChild(editedBadge);
290
+ meta.appendChild(title);
291
+ meta.appendChild(
292
+ el("div", "h", "Edit the markdown directly · the preview follows · decide below when it reads right"),
293
+ );
294
+ bar.appendChild(meta);
295
+
296
+ bar.appendChild(el("div", "sm-grow"));
297
+
298
+ previewBtn = mkButton("Preview", "ghost", "panel");
299
+ previewBtn.onclick = togglePreview;
300
+ bar.appendChild(previewBtn);
301
+
302
+ document.body.appendChild(bar);
303
+ }
304
+ function refreshChrome() {
305
+ if (editedBadge) editedBadge.style.display = planWasEdited() ? "" : "none";
306
+
307
+ var hasFeedback = commentText().length > 0;
308
+ if (sendBtn) {
309
+ sendBtn.classList.toggle("is-off", !hasFeedback);
310
+ }
311
+ if (approveBtn) {
312
+ var main = approveBtn.querySelector(".main span");
313
+ var sub = approveBtn.querySelector(".sub");
314
+ if (approveArmed && hasFeedback) {
315
+ approveBtn.classList.add("is-armed");
316
+ if (main) main.textContent = "Feedback not sent — approve anyway?";
317
+ if (sub) sub.style.display = "none";
318
+ } else {
319
+ approveArmed = false;
320
+ approveBtn.classList.remove("is-armed");
321
+ if (main) main.textContent = "Approve plan";
322
+ if (sub) sub.style.display = "";
323
+ }
324
+ }
325
+ if (previewBtn) {
326
+ previewBtn.classList.toggle("is-on", previewOpen);
327
+ btnLabel(previewBtn, previewOpen ? "Hide preview" : "Show preview");
328
+ }
329
+ }
330
+
331
+ // ---------- workspace: editor + live preview + comment dock ----------
332
+ function buildWorkspace() {
333
+ wsEl = el("div", "sm-o sm-ws");
334
+ wsEl.setAttribute("data-sm-overlay", "1");
335
+
336
+ var panes = el("div", "sm-panes");
337
+
338
+ edPane = el("div", "sm-ed");
339
+ editorEl = el("textarea", "sm-ed-ta");
340
+ editorEl.spellcheck = false;
341
+ editorEl.value = baseline;
342
+ editorEl.addEventListener("input", function () {
343
+ schedulePreview();
344
+ refreshChrome();
345
+ });
346
+ // Tab indents instead of walking focus out of the editor.
347
+ editorEl.addEventListener("keydown", function (e) {
348
+ if (e.key === "Tab") {
349
+ e.preventDefault();
350
+ var s = editorEl.selectionStart;
351
+ var t = editorEl.selectionEnd;
352
+ editorEl.value = editorEl.value.slice(0, s) + " " + editorEl.value.slice(t);
353
+ editorEl.selectionStart = editorEl.selectionEnd = s + 2;
354
+ schedulePreview();
355
+ refreshChrome();
356
+ }
357
+ });
358
+ edPane.appendChild(editorEl);
359
+ panes.appendChild(edPane);
360
+
361
+ // The preview pane adopts the server-rendered #plan-root; live re-renders
362
+ // replace its innerHTML from the editor content.
363
+ previewPane = el("div", "sm-prev");
364
+ var root = planRoot();
365
+ if (root) previewPane.appendChild(root);
366
+ panes.appendChild(previewPane);
367
+
368
+ wsEl.appendChild(panes);
369
+ document.body.appendChild(wsEl);
370
+ applyPreviewState(readPreviewPref());
371
+ }
372
+
373
+ // ---------- chat composer bar ----------
374
+ // Always visible at the bottom of the workspace — the whole decision in one
375
+ // strip: previous round's feedback read-only, the chat input ("tell the
376
+ // agent what to change…"), the round send button, and Approve with its
377
+ // consequence spelled out ("the agent builds the slides next").
378
+ function buildComposerBar() {
379
+ composerEl = el("div", "sm-composer");
380
+ composerEl.setAttribute("data-sm-overlay", "1");
381
+
382
+ if (CFG.previousComment) {
383
+ var prev = el("div", "sm-prev-note");
384
+ prev.innerHTML = ICONS.ghost + '<span class="lbl">Previous round</span>';
385
+ var txt = el("span", "txt");
386
+ txt.textContent = CFG.previousComment;
387
+ prev.appendChild(txt);
388
+ composerEl.appendChild(prev);
389
+ }
390
+
391
+ var row = el("div", "sm-composer-row");
392
+ commentEl = el("textarea", "sm-chat-ta");
393
+ commentEl.rows = 1;
394
+ commentEl.placeholder = "Tell the agent what to change — or edit the plan above yourself…";
395
+ commentEl.addEventListener("input", function () {
396
+ commentEl.style.height = "auto";
397
+ commentEl.style.height = Math.min(commentEl.scrollHeight, 150) + "px";
398
+ refreshChrome();
399
+ });
400
+ commentEl.addEventListener("keydown", function (e) {
401
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") requestChanges();
402
+ });
403
+ row.appendChild(commentEl);
404
+
405
+ sendBtn = el("button", "sm-send is-off");
406
+ sendBtn.title = "Send — the agent revises the plan and comes back";
407
+ sendBtn.innerHTML = ICONS.send;
408
+ sendBtn.onclick = requestChanges;
409
+ row.appendChild(sendBtn);
410
+
411
+ row.appendChild(el("div", "sm-composer-sep"));
412
+
413
+ approveBtn = el("button", "sm-approve");
414
+ approveBtn.innerHTML =
415
+ '<span class="main">' +
416
+ ICONS.check +
417
+ "<span>Approve plan</span></span>" +
418
+ '<span class="sub">the agent builds the slides next</span>';
419
+ approveBtn.onclick = function () {
420
+ // Approving with unsent feedback in the input discards it — confirm inline.
421
+ if (commentText() && !approveArmed) {
422
+ approveArmed = true;
423
+ refreshChrome();
424
+ return;
425
+ }
426
+ submit("approve");
427
+ };
428
+ row.appendChild(approveBtn);
429
+
430
+ composerEl.appendChild(row);
431
+ wsEl.appendChild(composerEl);
432
+ }
433
+
434
+ // ---------- waiting state (comments sent) ----------
435
+ // The tab does NOT veil over on a comments round: the plan stays readable,
436
+ // the editor locks, and the composer bar becomes the wait signal until the
437
+ // agent's next `slidemind plan` round reloads this tab (heartbeat polling).
438
+ function enterWaiting() {
439
+ if (editorEl) editorEl.readOnly = true;
440
+ if (wsEl) wsEl.classList.add("is-waiting");
441
+ document.title = WAITING_TITLE; // the tab label signals the state from the tab bar
442
+ if (composerEl) {
443
+ composerEl.innerHTML = "";
444
+ var w = el("div", "sm-wait");
445
+ w.appendChild(el("div", "sm-spin"));
446
+ waitTextEl = el(
447
+ "div",
448
+ null,
449
+ "<b>Feedback sent.</b> The agent is revising the plan — this tab refreshes " +
450
+ "automatically when the next round is ready. Feel free to keep reading, " +
451
+ "or come back later.",
452
+ );
453
+ w.appendChild(waitTextEl);
454
+ composerEl.appendChild(w);
455
+ }
456
+ // The permission prompt was raised at the send click (a user gesture, so the
457
+ // browser shows it); once granted, tell the user a notification will come.
458
+ (notifyPermPromise || Promise.resolve(notifyPermission())).then(function (perm) {
459
+ if (perm === "granted" && waitTextEl) {
460
+ waitTextEl.innerHTML += " You'll get a notification the moment it's ready.";
461
+ }
462
+ });
463
+ }
464
+
465
+ // ---------- next-round attention ----------
466
+ // A background tab cannot steal focus (by browser design). What it CAN do:
467
+ // fire a system notification whose click is allowed to focus the tab, and
468
+ // flag itself in the tab bar with an attention title until it is focused.
469
+ function notifyPermission() {
470
+ try {
471
+ return "Notification" in window ? Notification.permission : "unsupported";
472
+ } catch {
473
+ return "unsupported";
474
+ }
475
+ }
476
+ function requestNotifyPermission() {
477
+ try {
478
+ if (!("Notification" in window)) return Promise.resolve("unsupported");
479
+ if (Notification.permission !== "default") return Promise.resolve(Notification.permission);
480
+ return Promise.resolve(Notification.requestPermission());
481
+ } catch {
482
+ return Promise.resolve("unsupported");
483
+ }
484
+ }
485
+ // The reload wipes this JS context, so the "announce the new round" wish is
486
+ // parked in sessionStorage and honoured by the NEXT page load.
487
+ function markRoundReadyAndReload() {
488
+ try {
489
+ sessionStorage.setItem(ROUND_READY_FLAG, "1");
490
+ } catch {
491
+ /* storage blocked — the reload still lands, just without the announce */
492
+ }
493
+ location.reload();
494
+ }
495
+ function announceNewRound() {
496
+ var flagged = false;
497
+ try {
498
+ flagged = sessionStorage.getItem(ROUND_READY_FLAG) === "1";
499
+ if (flagged) sessionStorage.removeItem(ROUND_READY_FLAG);
500
+ } catch {
501
+ /* no storage — nothing to announce */
502
+ }
503
+ if (!flagged) return;
504
+ try {
505
+ window.focus(); // best-effort; most browsers ignore it for background tabs
506
+ } catch {
507
+ /* ignore */
508
+ }
509
+ if (!document.hidden) return; // user is already looking at it
510
+ var origTitle = document.title;
511
+ var restore = function () {
512
+ document.title = origTitle;
513
+ document.removeEventListener("visibilitychange", onVis);
514
+ window.removeEventListener("focus", restore);
515
+ };
516
+ var onVis = function () {
517
+ if (!document.hidden) restore();
518
+ };
519
+ document.title = ATTENTION_TITLE;
520
+ document.addEventListener("visibilitychange", onVis);
521
+ window.addEventListener("focus", restore);
522
+ try {
523
+ if (notifyPermission() === "granted") {
524
+ var n = new Notification("slidemind — plan ready", {
525
+ body: "The revised plan is ready for your review. Click to jump back to the tab.",
526
+ tag: "slidemind-plan-round", // replaces an earlier round's notification
527
+ });
528
+ n.onclick = function () {
529
+ try {
530
+ window.focus();
531
+ } catch {
532
+ /* ignore */
533
+ }
534
+ n.close();
535
+ };
536
+ }
537
+ } catch {
538
+ /* notification construction can throw on some platforms — title marker stands */
539
+ }
540
+ }
541
+
542
+ // ---------- live preview ----------
543
+ // Debounced re-render of the preview from the editor's current text, via the
544
+ // renderer the page ships (plan-md.mjs run in-browser). Absent the renderer
545
+ // the preview simply stays at the served render.
546
+ function schedulePreview() {
547
+ if (!window.__SLIDEMIND_RENDER_MD__) return;
548
+ if (renderTimer) clearTimeout(renderTimer);
549
+ renderTimer = setTimeout(renderPreview, RENDER_DEBOUNCE_MS);
550
+ }
551
+ function renderPreview() {
552
+ renderTimer = null;
553
+ var root = planRoot();
554
+ var render = window.__SLIDEMIND_RENDER_MD__;
555
+ if (!root || !render) return;
556
+ var st = previewPane ? previewPane.scrollTop : 0;
557
+ try {
558
+ root.innerHTML = render(editorEl.value, {
559
+ lineOffset: (CFG.bodyStartLine || 1) - 1,
560
+ });
561
+ } catch {
562
+ return; // never let a renderer hiccup take down the editor
563
+ }
564
+ if (previewPane) previewPane.scrollTop = st;
565
+ }
566
+
567
+ // ---------- preview collapse ----------
568
+ function readPreviewPref() {
569
+ try {
570
+ return localStorage.getItem(PREVIEW_PREF_KEY) !== "hidden";
571
+ } catch {
572
+ return true;
573
+ }
574
+ }
575
+ function applyPreviewState(open) {
576
+ previewOpen = open;
577
+ var panes = wsEl && wsEl.querySelector(".sm-panes");
578
+ if (panes) panes.classList.toggle("preview-hidden", !previewOpen);
579
+ refreshChrome();
580
+ }
581
+ function togglePreview() {
582
+ applyPreviewState(!previewOpen);
583
+ try {
584
+ localStorage.setItem(PREVIEW_PREF_KEY, previewOpen ? "shown" : "hidden");
585
+ } catch {
586
+ /* private mode etc. — the toggle still works for this tab */
587
+ }
588
+ if (previewOpen) renderPreview(); // catch up on edits made while hidden
589
+ }
590
+
591
+ // ---------- submit ----------
592
+ function requestChanges() {
593
+ if (!commentText()) return;
594
+ // Ask for notification permission HERE — the click is a user gesture, the
595
+ // only context where the browser will actually show the prompt.
596
+ notifyPermPromise = requestNotifyPermission();
597
+ submit("comments");
598
+ }
599
+ function submit(decision) {
600
+ if (submitted) return;
601
+ submitted = true;
602
+ var payload = {
603
+ decision: decision,
604
+ comment: decision === "comments" ? commentText() : null,
605
+ editedPlan: planWasEdited() ? editorEl.value : null,
606
+ };
607
+ fetch(CFG.endpoint, {
608
+ method: "POST",
609
+ headers: { "Content-Type": "application/json" },
610
+ body: JSON.stringify(payload),
611
+ })
612
+ .then(function () {
613
+ // Approve ends the session — the brand build animation plays, then the
614
+ // tab closes itself. A comments round keeps the tab OPEN and readable:
615
+ // the composer becomes a spinner + wait note while the heartbeat
616
+ // polling waits for the next run, which reloads this same tab.
617
+ if (decision === "approve") {
618
+ showApproved();
619
+ } else {
620
+ enterWaiting();
621
+ startNextRoundPolling();
622
+ }
623
+ })
624
+ .catch(function () {
625
+ submitted = false;
626
+ alert("Could not reach the slidemind plan server — is the terminal command still running?");
627
+ });
628
+ }
629
+ function requestTabClose() {
630
+ setTimeout(function () {
631
+ try {
632
+ window.open("", "_self");
633
+ window.close();
634
+ } catch {
635
+ // Some browsers only allow script-opened tabs to close themselves.
636
+ }
637
+ }, 120);
638
+ }
639
+ // After a comments round the tab stays open and polls the heartbeat so the
640
+ // NEXT run (which binds the same deterministic port) reloads this tab instead
641
+ // of opening a fresh browser window.
642
+ function startNextRoundPolling() {
643
+ var startedAt = Date.now();
644
+ var pollMs = CFG.pollMs || 1000;
645
+ var ttl = CFG.pollTtlMs || 35 * 60 * 1000;
646
+ var hb = CFG.heartbeat || "/__plan/heartbeat";
647
+ var timer = setInterval(function () {
648
+ if (Date.now() - startedAt > ttl) {
649
+ clearInterval(timer);
650
+ showSessionEnded();
651
+ return;
652
+ }
653
+ fetch(hb, { cache: "no-store" })
654
+ .then(function (r) { return r.ok ? r.json() : null; })
655
+ .then(function (data) {
656
+ // Reload only for a genuine NEWER slidemind plan round: shape-guard the
657
+ // service marker (a foreign app / the preview gate may have taken the
658
+ // port) and require a different nonce (same nonce = this run's own
659
+ // orphan — never self-reload).
660
+ if (!data || data.service !== "slidemind-plan" || !data.round) return;
661
+ if (data.round !== CFG.round) {
662
+ clearInterval(timer);
663
+ markRoundReadyAndReload();
664
+ }
665
+ })
666
+ .catch(function () { /* server down between rounds — keep polling */ });
667
+ }, pollMs);
668
+ }
669
+
670
+ // ---------- done veil ----------
671
+ // Shared full-screen veil (logo + wordmark + status + sub). Replaces any veil
672
+ // already shown rather than stacking a second one, so a comments round's
673
+ // "waiting" veil can be swapped for "session ended" in place.
674
+ function showVeil(statusHtml, subText, subPulse) {
675
+ if (bar) bar.style.display = "none";
676
+ if (wsEl) wsEl.style.display = "none";
677
+ var existing = document.querySelector(".sm-veil[data-sm-overlay]");
678
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
679
+ var veil = el("div", "sm-o sm-veil");
680
+ veil.setAttribute("data-sm-overlay", "1");
681
+ veil.appendChild(logoNode("sm-logo", "-v"));
682
+ veil.appendChild(el("div", "word", wordmarkHtml()));
683
+ veil.appendChild(el("div", "status", statusHtml));
684
+ veil.appendChild(el("div", "sub" + (subPulse ? " sm-pulse" : ""), subText));
685
+ document.body.appendChild(veil);
686
+ }
687
+ // Approve's exit moment: the full-screen brand animation — rising logo,
688
+ // drifting wordmark — announcing that the agent now builds the slides. The
689
+ // tab closes itself after the animation has had time to land; if the browser
690
+ // refuses, the pulsing veil keeps telling the user what's happening.
691
+ function showApproved() {
692
+ showVeil(
693
+ ICONS.check + "<span>Plan approved</span>",
694
+ "The agent is building your slides now — you can close this tab.",
695
+ true,
696
+ );
697
+ setTimeout(requestTabClose, APPROVE_CLOSE_DELAY_MS);
698
+ }
699
+ function showSessionEnded() {
700
+ showVeil("<span>Session ended</span>", "You can close this tab.");
701
+ }
702
+
703
+ // ---------- assets ----------
704
+ function injectAssets() {
705
+ var style = el("style");
706
+ style.id = "sm-plan-style";
707
+ style.textContent = CSS;
708
+ document.head.appendChild(style);
709
+
710
+ var pre1 = el("link");
711
+ pre1.rel = "preconnect";
712
+ pre1.href = "https://fonts.googleapis.com";
713
+ var pre2 = el("link");
714
+ pre2.rel = "preconnect";
715
+ pre2.href = "https://fonts.gstatic.com";
716
+ pre2.crossOrigin = "anonymous";
717
+ var fonts = el("link");
718
+ fonts.rel = "stylesheet";
719
+ fonts.href =
720
+ "https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600&family=Urbanist:wght@300;400&display=swap";
721
+ document.head.appendChild(pre1);
722
+ document.head.appendChild(pre2);
723
+ document.head.appendChild(fonts);
724
+ }
725
+
726
+ // ---------- wire up ----------
727
+ function init() {
728
+ var root = planRoot();
729
+ if (!root) {
730
+ setTimeout(init, 50);
731
+ return;
732
+ }
733
+ injectAssets();
734
+ buildChrome();
735
+ buildWorkspace();
736
+ buildComposerBar();
737
+ refreshChrome();
738
+ editorEl.focus();
739
+ announceNewRound(); // notification + title marker when this load IS the next round
740
+ }
741
+
742
+ if (document.readyState === "loading") {
743
+ document.addEventListener("DOMContentLoaded", init);
744
+ } else {
745
+ init();
746
+ }
747
+ })();