@ammduncan/easel 0.2.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.
@@ -0,0 +1,1215 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ const cfg = window.__EASEL__ || {};
5
+ const sessionId = cfg.sessionId;
6
+
7
+ const cardsEl = document.getElementById("cards");
8
+ const emptyEl = document.getElementById("empty-state");
9
+ const countEl = document.getElementById("push-count");
10
+ const prunedEl = document.getElementById("pruned-marker");
11
+ const liveDotEl = document.getElementById("live-dot");
12
+ const liveLabelEl = document.getElementById("live-label");
13
+ const newPillEl = document.getElementById("new-pill");
14
+ const newPillText = document.getElementById("new-pill-text");
15
+ const themeToggleEl = document.getElementById("theme-toggle");
16
+ const presetBtnEls = Array.from(document.querySelectorAll(".preset-btn"));
17
+ const densityBtnEls = Array.from(document.querySelectorAll(".density-btn"));
18
+ const switcherBtnEl = document.getElementById("switcher-btn");
19
+ const switcherMenuEl = document.getElementById("switcher-menu");
20
+ const projectLabelEl = document.getElementById("project-label");
21
+
22
+ const BOTTOM_THRESHOLD_PX = 220;
23
+ const CONFIG_KEY = "easel:config";
24
+ const LAST_VISITED_KEY = "easel:last-visited";
25
+ const PRESETS = ["paper", "aurora", "slate"];
26
+ const DENSITIES = ["carded", "flat"];
27
+
28
+ /* The token block injected into every iframe wrapper — six combos so
29
+ pushed HTML themes correctly regardless of host preset/mode. */
30
+ const PRESET_TOKENS_CSS = `
31
+ :root[data-preset="paper"][data-theme="light"] {
32
+ --ds-bg:#f4efe2;--ds-bg-elev:#f8f3e6;--ds-surface:#faf6ee;--ds-surface-soft:#f0ead9;
33
+ --ds-ink:#2a261e;--ds-ink-soft:#524b3c;--ds-muted:#756c57;
34
+ --ds-line:#d5cdb6;--ds-line-soft:#e3dcc6;
35
+ --ds-accent:#c97a1c;--ds-accent-soft:#f7e8c0;--ds-accent-ink:#fff;
36
+ --ds-code-bg:#2a261e;--ds-code-ink:#eae5d5;
37
+ --ds-shadow-md:0 1px 2px rgba(70,50,10,.06),0 18px 36px rgba(70,50,10,.1);
38
+ color-scheme:light;
39
+ }
40
+ :root[data-preset="paper"][data-theme="dark"] {
41
+ --ds-bg:#1c1b18;--ds-bg-elev:#25241f;--ds-surface:#25241f;--ds-surface-soft:#20201c;
42
+ --ds-ink:#ede9e0;--ds-ink-soft:#bbb5a8;--ds-muted:#888273;
43
+ --ds-line:#423f37;--ds-line-soft:#312f29;
44
+ --ds-accent:#f4bf5e;--ds-accent-soft:#3d3322;--ds-accent-ink:#1f1d18;
45
+ --ds-code-bg:#161514;--ds-code-ink:#eae5d5;
46
+ --ds-shadow-md:inset 0 1px 0 rgba(255,255,255,.045),0 1px 2px rgba(0,0,0,.55),0 18px 38px rgba(0,0,0,.45);
47
+ color-scheme:dark;
48
+ }
49
+ :root[data-preset="aurora"][data-theme="light"] {
50
+ --ds-bg:#f5f3fa;--ds-bg-elev:#fafaff;--ds-surface:#fff;--ds-surface-soft:#f0eef7;
51
+ --ds-ink:#1c1d24;--ds-ink-soft:#4a4d5a;--ds-muted:#7a7d8c;
52
+ --ds-line:#e1dff0;--ds-line-soft:#ebe9f5;
53
+ --ds-accent:#6d4eff;--ds-accent-soft:#ebe7ff;--ds-accent-ink:#fff;
54
+ --ds-code-bg:#1c1d24;--ds-code-ink:#ebe7ff;
55
+ --ds-shadow-md:0 1px 2px rgba(60,50,120,.05),0 18px 36px rgba(60,50,120,.08);
56
+ color-scheme:light;
57
+ }
58
+ :root[data-preset="aurora"][data-theme="dark"] {
59
+ --ds-bg:#0d0f14;--ds-bg-elev:#14171f;--ds-surface:#161a23;--ds-surface-soft:#11141a;
60
+ --ds-ink:#e7e9ee;--ds-ink-soft:#b9bdc6;--ds-muted:#8b909a;
61
+ --ds-line:rgba(143,160,200,.14);--ds-line-soft:rgba(143,160,200,.08);
62
+ --ds-accent:#b8c8ff;--ds-accent-soft:rgba(140,170,255,.12);--ds-accent-ink:#0d0f14;
63
+ --ds-code-bg:#07080a;--ds-code-ink:#e7e9ee;
64
+ --ds-shadow-md:inset 0 1px 0 rgba(255,255,255,.045),0 0 0 1px rgba(123,97,255,.06),0 24px 60px rgba(0,0,0,.55),0 0 80px -20px rgba(123,97,255,.25);
65
+ color-scheme:dark;
66
+ }
67
+ :root[data-preset="slate"][data-theme="light"] {
68
+ --ds-bg:#ecebe5;--ds-bg-elev:#f6f4ee;--ds-surface:#f6f4ee;--ds-surface-soft:#ecebe3;
69
+ --ds-ink:#1a1916;--ds-ink-soft:#34322d;--ds-muted:#76746c;
70
+ --ds-line:#d8d5cb;--ds-line-soft:#e1ddd2;
71
+ --ds-accent:#2f5fd1;--ds-accent-soft:#e4ebfb;--ds-accent-ink:#fff;
72
+ --ds-code-bg:#1c1b18;--ds-code-ink:#f1ede1;
73
+ --ds-shadow-md:0 1px 2px rgba(40,30,10,.05),0 16px 32px rgba(40,30,10,.08);
74
+ color-scheme:light;
75
+ }
76
+ :root[data-preset="slate"][data-theme="dark"] {
77
+ --ds-bg:#0c0d10;--ds-bg-elev:#15171c;--ds-surface:#15171c;--ds-surface-soft:#1c1f25;
78
+ --ds-ink:#f5f5f5;--ds-ink-soft:#d4d4d8;--ds-muted:#9ca3af;
79
+ --ds-line:#23262d;--ds-line-soft:#1c1f25;
80
+ --ds-accent:#7dd3fc;--ds-accent-soft:rgba(125,211,252,.16);--ds-accent-ink:#07242e;
81
+ --ds-code-bg:#07080a;--ds-code-ink:#f5f5f5;
82
+ --ds-shadow-md:0 1px 2px rgba(0,0,0,.4),0 12px 28px rgba(0,0,0,.45);
83
+ color-scheme:dark;
84
+ }
85
+ `;
86
+
87
+ /* Semantic chips — universal across presets. Authors use:
88
+ <span class="chip bug">BUG</span> / .ux / .polish / .ok / .info
89
+ to get accessible, glow-haloed badges that work in both modes. */
90
+ const SEMANTIC_CHIPS_CSS = `
91
+ .chip {
92
+ display: inline-block;
93
+ font-size: 11px;
94
+ letter-spacing: 0.08em;
95
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
96
+ padding: 3px 9px;
97
+ border-radius: 999px;
98
+ text-transform: uppercase;
99
+ border: 1px solid transparent;
100
+ font-weight: 600;
101
+ }
102
+ :root[data-theme="light"] .chip.bug { background:#fef2f2; color:#b91c1c; border-color:#fecaca; box-shadow:0 0 12px -6px rgba(185,28,28,.5); }
103
+ :root[data-theme="dark"] .chip.bug { background:#2a1518; color:#ffaba0; border-color:#4a1f25; box-shadow:0 0 12px -4px rgba(255,119,119,.45); }
104
+ :root[data-theme="light"] .chip.ux { background:#eff6ff; color:#1d4ed8; border-color:#bfdbfe; box-shadow:0 0 12px -6px rgba(29,78,216,.45); }
105
+ :root[data-theme="dark"] .chip.ux { background:#15212b; color:#93cef0; border-color:#1f3848; box-shadow:0 0 12px -4px rgba(140,170,255,.4); }
106
+ :root[data-theme="light"] .chip.polish { background:#faf5ff; color:#7c3aed; border-color:#e9d5ff; box-shadow:0 0 12px -6px rgba(124,58,237,.45); }
107
+ :root[data-theme="dark"] .chip.polish { background:#1b2230; color:#b8bfd2; border-color:#2a334b; box-shadow:0 0 12px -4px rgba(186,130,255,.4); }
108
+ :root[data-theme="light"] .chip.ok { background:#f0fdf4; color:#028043; border-color:#bbf7d0; box-shadow:0 0 12px -6px rgba(2,128,67,.45); }
109
+ :root[data-theme="dark"] .chip.ok { background:#052e16; color:#6ee7b7; border-color:#134e29; box-shadow:0 0 12px -4px rgba(110,231,183,.4); }
110
+ :root[data-theme="light"] .chip.info { background:#ecfeff; color:#0e7490; border-color:#a5f3fc; box-shadow:0 0 12px -6px rgba(14,116,144,.45); }
111
+ :root[data-theme="dark"] .chip.info { background:#0a1c22; color:#67e8f9; border-color:#155060; box-shadow:0 0 12px -4px rgba(103,232,249,.4); }
112
+ .chip.accent { background:var(--ds-accent-soft); color:var(--ds-accent); border-color:transparent; box-shadow:0 0 12px -4px color-mix(in srgb, var(--ds-accent) 40%, transparent); }
113
+ `;
114
+
115
+ const unreadIds = new Set();
116
+ let totalPushes = 0;
117
+ const iframes = new Set();
118
+ const cardObservers = new Map(); // pushId → IntersectionObserver
119
+ let bumpAt = 0;
120
+
121
+ /* ============================================================
122
+ Self-measure message bridge — iframes post their measured body
123
+ height; we apply it. Reliable across font loads, image loads,
124
+ and dynamic content (the iframe knows when its DOM mutates).
125
+ ============================================================ */
126
+
127
+ window.addEventListener("message", (e) => {
128
+ const data = e && e.data;
129
+ if (!data) return;
130
+ if (data.type === "easel:size") {
131
+ if (!data.pushId || typeof data.height !== "number") return;
132
+ const iframe = cardsEl.querySelector(
133
+ 'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
134
+ );
135
+ if (!iframe) return;
136
+ iframe.style.height = Math.max(0, Math.ceil(data.height)) + "px";
137
+ return;
138
+ }
139
+ if (data.type === "easel:click") {
140
+ // An iframe was clicked — close any open dropdowns in the parent.
141
+ if (switcherMenuEl && !switcherMenuEl.hidden) {
142
+ switcherMenuEl.hidden = true;
143
+ switcherBtnEl.setAttribute("aria-expanded", "false");
144
+ }
145
+ return;
146
+ }
147
+ if (data.type === "easel:image-ready") {
148
+ // Iframe rasterised itself; trigger a download in the parent.
149
+ const a = document.createElement("a");
150
+ a.href = data.dataUrl;
151
+ a.download = data.filename || "push.png";
152
+ document.body.appendChild(a);
153
+ a.click();
154
+ a.remove();
155
+ const btn = cardsEl.querySelector(
156
+ 'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
157
+ );
158
+ if (btn && btn.closest(".push")) {
159
+ const ex = btn.closest(".push").querySelector(".push-export");
160
+ if (ex) delete ex.dataset.loading;
161
+ }
162
+ return;
163
+ }
164
+ });
165
+
166
+ function cssEscape(s) {
167
+ if (window.CSS && CSS.escape) return CSS.escape(s);
168
+ return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
169
+ }
170
+
171
+ /* ============================================================
172
+ Theming
173
+ ============================================================ */
174
+
175
+ function currentTheme() {
176
+ return document.documentElement.getAttribute("data-theme") || "dark";
177
+ }
178
+ function currentPreset() {
179
+ return document.documentElement.getAttribute("data-preset") || "paper";
180
+ }
181
+ function currentDensity() {
182
+ return document.documentElement.getAttribute("data-density") || "carded";
183
+ }
184
+
185
+ function applyConfig(patch, opts) {
186
+ const theme = patch.theme === "light" || patch.theme === "dark"
187
+ ? patch.theme
188
+ : currentTheme();
189
+ const preset = PRESETS.includes(patch.preset) ? patch.preset : currentPreset();
190
+ const density = DENSITIES.includes(patch.density) ? patch.density : currentDensity();
191
+ document.documentElement.setAttribute("data-theme", theme);
192
+ document.documentElement.setAttribute("data-preset", preset);
193
+ document.documentElement.setAttribute("data-density", density);
194
+ syncPresetButtons(preset);
195
+ syncDensityButtons(density);
196
+ if (!opts || !opts.skipPersist) {
197
+ try {
198
+ localStorage.setItem(CONFIG_KEY, JSON.stringify({ preset, theme, density }));
199
+ } catch (e) {
200
+ /* ignore */
201
+ }
202
+ }
203
+ broadcastConfigToIframes({ preset, theme, density });
204
+ if (!opts || !opts.skipServer) {
205
+ pushConfigToServer({ preset, theme, density });
206
+ }
207
+ }
208
+
209
+ function syncPresetButtons(active) {
210
+ presetBtnEls.forEach((btn) => {
211
+ btn.classList.toggle("active", btn.dataset.preset === active);
212
+ });
213
+ }
214
+ function syncDensityButtons(active) {
215
+ densityBtnEls.forEach((btn) => {
216
+ btn.classList.toggle("active", btn.dataset.density === active);
217
+ });
218
+ }
219
+
220
+ function broadcastConfigToIframes(cfg) {
221
+ iframes.forEach((iframe) => {
222
+ try {
223
+ iframe.contentWindow &&
224
+ iframe.contentWindow.postMessage(
225
+ { type: "easel:config", ...cfg },
226
+ "*",
227
+ );
228
+ } catch (e) {
229
+ /* ignore */
230
+ }
231
+ });
232
+ }
233
+
234
+ async function pushConfigToServer(cfg) {
235
+ try {
236
+ await fetch("/api/config", {
237
+ method: "POST",
238
+ headers: { "content-type": "application/json" },
239
+ body: JSON.stringify(cfg),
240
+ });
241
+ } catch {
242
+ /* server will catch up via SSE on next event */
243
+ }
244
+ }
245
+
246
+ themeToggleEl.addEventListener("click", () => {
247
+ applyConfig({ theme: currentTheme() === "dark" ? "light" : "dark" });
248
+ });
249
+ presetBtnEls.forEach((btn) => {
250
+ btn.addEventListener("click", () => {
251
+ applyConfig({ preset: btn.dataset.preset });
252
+ });
253
+ });
254
+ densityBtnEls.forEach((btn) => {
255
+ btn.addEventListener("click", () => {
256
+ applyConfig({ density: btn.dataset.density });
257
+ });
258
+ });
259
+ syncPresetButtons(currentPreset());
260
+ syncDensityButtons(currentDensity());
261
+
262
+ /* ============================================================
263
+ Scroll helpers
264
+ ============================================================ */
265
+
266
+ function nearBottom() {
267
+ const remaining =
268
+ document.documentElement.scrollHeight -
269
+ window.scrollY -
270
+ window.innerHeight;
271
+ return remaining <= BOTTOM_THRESHOLD_PX;
272
+ }
273
+
274
+
275
+ function scrollToBottom(smooth) {
276
+ window.scrollTo({
277
+ top: document.documentElement.scrollHeight,
278
+ behavior: smooth ? "smooth" : "auto",
279
+ });
280
+ }
281
+
282
+ function scrollToLatestCard(smooth) {
283
+ const last = cardsEl.lastElementChild;
284
+ if (!last) {
285
+ scrollToBottom(smooth);
286
+ return;
287
+ }
288
+ last.scrollIntoView({
289
+ behavior: smooth ? "smooth" : "auto",
290
+ block: "start",
291
+ });
292
+ }
293
+
294
+ /* ============================================================
295
+ Formatting
296
+ ============================================================ */
297
+
298
+ function formatTime(ts) {
299
+ const d = new Date(ts);
300
+ const hh = String(d.getHours()).padStart(2, "0");
301
+ const mm = String(d.getMinutes()).padStart(2, "0");
302
+ return hh + ":" + mm;
303
+ }
304
+
305
+ function setPushCount(n) {
306
+ totalPushes = n;
307
+ countEl.textContent = n === 1 ? "1 push" : n + " pushes";
308
+ emptyEl.hidden = n > 0;
309
+ }
310
+
311
+ function setPrunedCount(n) {
312
+ if (n > 0) {
313
+ prunedEl.hidden = false;
314
+ prunedEl.textContent =
315
+ "… " + n + " earlier push" + (n === 1 ? "" : "es") + " pruned";
316
+ } else {
317
+ prunedEl.hidden = true;
318
+ }
319
+ }
320
+
321
+ function updateLive(connected) {
322
+ if (connected) {
323
+ liveDotEl.classList.remove("offline");
324
+ liveLabelEl.textContent = "live";
325
+ } else {
326
+ liveDotEl.classList.add("offline");
327
+ liveLabelEl.textContent = "reconnecting…";
328
+ }
329
+ }
330
+
331
+ /* ============================================================
332
+ Card rendering
333
+ ============================================================ */
334
+
335
+ function renderPush(push, opts) {
336
+ const card = document.createElement("article");
337
+ card.className = "push";
338
+ if (opts && opts.fresh) card.classList.add("fresh");
339
+ card.id = "push-" + push.id;
340
+
341
+ const meta = document.createElement("div");
342
+ meta.className = "push-meta";
343
+
344
+ const idx = document.createElement("span");
345
+ idx.className = "push-index";
346
+ idx.textContent = "#" + push.index;
347
+ meta.appendChild(idx);
348
+
349
+ const title = document.createElement("span");
350
+ title.className = "push-title";
351
+ title.textContent = push.title || "(untitled)";
352
+ meta.appendChild(title);
353
+
354
+ if (push.kind) {
355
+ const kind = document.createElement("span");
356
+ kind.className = "push-kind";
357
+ kind.textContent = push.kind;
358
+ meta.appendChild(kind);
359
+ }
360
+
361
+ const time = document.createElement("span");
362
+ time.className = "push-time";
363
+ time.textContent = formatTime(push.createdAt);
364
+ meta.appendChild(time);
365
+
366
+ const exportBtn = document.createElement("button");
367
+ exportBtn.className = "push-export";
368
+ exportBtn.type = "button";
369
+ exportBtn.title = "Save as PNG";
370
+ exportBtn.setAttribute("aria-label", "Save push as PNG");
371
+ exportBtn.innerHTML =
372
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>';
373
+ exportBtn.addEventListener("click", (e) => {
374
+ e.stopPropagation();
375
+ e.preventDefault();
376
+ const safeTitle = (push.title || "push-" + push.index)
377
+ .toLowerCase()
378
+ .replace(/[^a-z0-9-]+/g, "-")
379
+ .replace(/^-+|-+$/g, "")
380
+ .slice(0, 60) || "push";
381
+ exportBtn.dataset.loading = "true";
382
+
383
+ // Match the export bg to what the user sees inside this card:
384
+ // carded → card's elevated surface (--ds-bg-elev)
385
+ // flat → page canvas (--ds-bg) since the iframe body is transparent
386
+ const rootStyle = getComputedStyle(document.documentElement);
387
+ const isFlat = currentDensity() === "flat";
388
+ const bgVar = isFlat ? "--ds-bg" : "--ds-bg-elev";
389
+ const bgColor = rootStyle.getPropertyValue(bgVar).trim() || "#ffffff";
390
+
391
+ try {
392
+ iframe.contentWindow &&
393
+ iframe.contentWindow.postMessage(
394
+ {
395
+ type: "easel:image",
396
+ pushId: push.id,
397
+ filename: safeTitle + ".png",
398
+ bgColor,
399
+ },
400
+ "*",
401
+ );
402
+ } catch (err) {
403
+ delete exportBtn.dataset.loading;
404
+ console.error("[easel] export failed", err);
405
+ }
406
+ });
407
+ meta.appendChild(exportBtn);
408
+
409
+ const del = document.createElement("button");
410
+ del.className = "push-del";
411
+ del.type = "button";
412
+ del.title = "Delete this push";
413
+ del.setAttribute("aria-label", "Delete this push");
414
+ del.innerHTML =
415
+ '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 4h9M6 4V2.5h4V4M5 4l.5 9a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1L11 4M7 6.5v5M9 6.5v5"/></svg>';
416
+ del.addEventListener("click", async (e) => {
417
+ e.stopPropagation();
418
+ e.preventDefault();
419
+ const label = push.title ? `"${push.title}"` : `push #${push.index}`;
420
+ if (!window.confirm(`Delete ${label}? This can't be undone.`)) return;
421
+ try {
422
+ await fetch(
423
+ "/api/sessions/" +
424
+ encodeURIComponent(sessionId) +
425
+ "/pushes/" +
426
+ encodeURIComponent(push.id),
427
+ { method: "DELETE" },
428
+ );
429
+ removeCardFromDom(push.id);
430
+ } catch (err) {
431
+ console.error("[easel] delete failed", err);
432
+ }
433
+ });
434
+ meta.appendChild(del);
435
+
436
+ card.appendChild(meta);
437
+
438
+ const body = document.createElement("div");
439
+ body.className = "push-body";
440
+
441
+ const iframe = document.createElement("iframe");
442
+ iframe.setAttribute("sandbox", "allow-scripts allow-modals");
443
+ iframe.setAttribute("scrolling", "no");
444
+ iframe.setAttribute("title", push.title || "push " + push.index);
445
+ iframe.dataset.pushId = push.id;
446
+ iframe.srcdoc = wrapPushedHtml(push.html, currentTheme(), push.id);
447
+ iframe.addEventListener("load", () => {
448
+ iframes.add(iframe);
449
+ // Primary path: the iframe self-measures and posts back size via
450
+ // `easel:size` — handled by the message listener at the
451
+ // top of this module. No DOM peeking from the parent (would fail
452
+ // under cross-origin sandbox anyway).
453
+ });
454
+ body.appendChild(iframe);
455
+
456
+ card.appendChild(body);
457
+ return card;
458
+ }
459
+
460
+ /* ============================================================
461
+ Wrapper for pushed HTML
462
+ Bakes in: design tokens (light + dark), Rule 30 typography
463
+ defaults, generous whitespace, theme postMessage listener.
464
+ Authors can either:
465
+ - write plain HTML (<h1>, <h2>, <p>, etc.) — gets styled for free, OR
466
+ - write their own <style> and override anything.
467
+ ============================================================ */
468
+ function wrapPushedHtml(html, theme, pushId) {
469
+ // Authors sometimes wrap payloads in <![CDATA[ ... ]]> (treating html
470
+ // like CDATA-in-XML). Strip the XML-ism before doing anything else —
471
+ // otherwise the iframe renders the CDATA tags as visible text.
472
+ let cleaned = (html || "").trim();
473
+ if (cleaned.startsWith("<![CDATA[") && cleaned.endsWith("]]>")) {
474
+ cleaned = cleaned.slice(9, -3).trim();
475
+ }
476
+ const preset = currentPreset();
477
+ const lower = cleaned.toLowerCase();
478
+ if (lower.startsWith("<!doctype") || lower.startsWith("<html")) {
479
+ return injectBridge(cleaned, theme, preset, pushId);
480
+ }
481
+ return buildDefaultWrapper(cleaned, theme, preset, pushId);
482
+ }
483
+
484
+ function selfMeasureScript(pushId) {
485
+ return (
486
+ "(function(){var ID=" +
487
+ JSON.stringify(pushId) +
488
+ ";function measure(){var b=document.body,h=document.documentElement;if(!b)return 0;return Math.max(b.getBoundingClientRect().bottom,b.scrollHeight,h.scrollHeight)}function send(){try{parent.postMessage({type:'easel:size',pushId:ID,height:measure()},'*')}catch(e){}}send();window.addEventListener('load',send);window.addEventListener('resize',send);if(document.fonts&&document.fonts.ready){document.fonts.ready.then(send).catch(function(){})}if(window.ResizeObserver){var ro=new ResizeObserver(send);if(document.body)ro.observe(document.body);ro.observe(document.documentElement)}var mo=new MutationObserver(send);mo.observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true});setTimeout(send,250);setTimeout(send,800);setTimeout(send,1600)})();"
489
+ );
490
+ }
491
+
492
+ function buildDefaultWrapper(body, theme, preset, pushId) {
493
+ const density = currentDensity();
494
+ return `<!DOCTYPE html>
495
+ <html data-theme="${theme}" data-preset="${preset}" data-density="${density}">
496
+ <head>
497
+ <meta charset="utf-8" />
498
+ <base target="_blank" />
499
+ <link rel="preconnect" href="https://rsms.me/" />
500
+ <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
501
+ <script src="https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js"></script>
502
+ <style>
503
+ ${PRESET_TOKENS_CSS}
504
+ ${SEMANTIC_CHIPS_CSS}
505
+ *, *::before, *::after { box-sizing: border-box; }
506
+ html, body {
507
+ margin: 0;
508
+ background: var(--ds-bg-elev);
509
+ color: var(--ds-ink);
510
+ font-family: "Inter", -apple-system, "SF Pro Text", system-ui, sans-serif;
511
+ font-feature-settings: "cv11", "ss01";
512
+ line-height: 1.55;
513
+ -webkit-font-smoothing: antialiased;
514
+ transition: background 200ms ease, color 200ms ease;
515
+ }
516
+ body {
517
+ padding: 40px clamp(28px, 4vw, 64px) 48px;
518
+ max-width: 1400px;
519
+ margin: 0 auto;
520
+ }
521
+ @media (min-width: 2000px) {
522
+ body { max-width: 1600px; }
523
+ }
524
+ /* Constrain prose to a comfortable reading length, but let visual blocks
525
+ (cards, grids, tables, mockups) use the full card width. */
526
+ body > p, body > .deck, body > .lede, body > ul, body > ol, body > blockquote,
527
+ body > h1, body > h2, body > h3, body > h4 {
528
+ max-width: 880px;
529
+ }
530
+ body > *:first-child { margin-top: 0 !important; }
531
+ body > *:last-child { margin-bottom: 0 !important; }
532
+ .wrap { display: block; }
533
+ .kicker {
534
+ display: block;
535
+ font-size: 13px;
536
+ letter-spacing: 0.14em;
537
+ text-transform: uppercase;
538
+ color: var(--ds-muted);
539
+ font-weight: 500;
540
+ margin-bottom: 14px;
541
+ }
542
+ h1 {
543
+ font-size: 40px;
544
+ font-weight: 500;
545
+ letter-spacing: -0.025em;
546
+ line-height: 1.08;
547
+ margin: 0 0 18px;
548
+ }
549
+ .deck, .lede {
550
+ font-size: 19px;
551
+ line-height: 1.55;
552
+ color: var(--ds-ink-soft);
553
+ margin: 0 0 28px;
554
+ max-width: 720px;
555
+ }
556
+ h2 {
557
+ font-size: 26px;
558
+ font-weight: 500;
559
+ letter-spacing: -0.02em;
560
+ margin: 36px 0 12px;
561
+ }
562
+ h3 {
563
+ font-size: 19px;
564
+ font-weight: 600;
565
+ letter-spacing: -0.005em;
566
+ margin: 24px 0 8px;
567
+ }
568
+ h4 { font-size: 15px; font-weight: 600; margin: 20px 0 6px; }
569
+ p {
570
+ font-size: 18px;
571
+ margin: 0 0 14px;
572
+ color: var(--ds-ink-soft);
573
+ }
574
+ a { color: var(--ds-accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--ds-accent) 40%, transparent); }
575
+ a:hover { border-bottom-color: var(--ds-accent); }
576
+ ul, ol { padding-left: 22px; margin: 0 0 18px; }
577
+ li { font-size: 18px; margin-bottom: 6px; color: var(--ds-ink-soft); }
578
+ code {
579
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
580
+ font-size: 0.92em;
581
+ background: var(--ds-surface-soft);
582
+ color: var(--ds-ink);
583
+ padding: 2px 6px;
584
+ border-radius: 5px;
585
+ }
586
+ pre {
587
+ background: var(--ds-code-bg);
588
+ color: var(--ds-code-ink);
589
+ padding: 18px 22px;
590
+ border-radius: 12px;
591
+ overflow: auto;
592
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
593
+ font-size: 13.5px;
594
+ line-height: 1.7;
595
+ margin: 16px 0 24px;
596
+ }
597
+ pre code { background: transparent; padding: 0; color: inherit; font-size: inherit; }
598
+ blockquote {
599
+ border-left: 3px solid var(--ds-accent);
600
+ margin: 18px 0;
601
+ padding: 4px 0 4px 20px;
602
+ color: var(--ds-ink-soft);
603
+ font-size: 18px;
604
+ }
605
+ .card, .panel {
606
+ background: var(--ds-surface);
607
+ border: 1px solid var(--ds-line);
608
+ border-radius: 14px;
609
+ padding: 24px 28px;
610
+ margin: 0 0 20px;
611
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.04);
612
+ }
613
+ hr {
614
+ border: 0;
615
+ border-top: 1px solid var(--ds-line);
616
+ margin: 36px 0;
617
+ }
618
+ table {
619
+ width: 100%;
620
+ border-collapse: collapse;
621
+ font-size: 15px;
622
+ margin: 18px 0 24px;
623
+ }
624
+ th, td {
625
+ text-align: left;
626
+ padding: 10px 12px;
627
+ border-bottom: 1px solid var(--ds-line);
628
+ }
629
+ th { color: var(--ds-muted); font-weight: 500; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; }
630
+ img { max-width: 100%; height: auto; border-radius: 10px; }
631
+ :root[data-density="flat"] html,
632
+ :root[data-density="flat"] body { background: transparent; }
633
+
634
+ @media print {
635
+ /* Always print on white paper with dark text — ignore the host theme. */
636
+ :root, html, body {
637
+ background: #ffffff !important;
638
+ color: #111 !important;
639
+ color-scheme: light !important;
640
+ }
641
+ body { padding: 24px !important; max-width: none !important; }
642
+ body > p, body > .deck, body > .lede, body > ul, body > ol, body > blockquote,
643
+ body > h1, body > h2, body > h3, body > h4 { max-width: none !important; }
644
+ pre, code { background: #f4f3ed !important; color: #111 !important; border: 1px solid #ddd; }
645
+ .card, .panel { background: #fff !important; border: 1px solid #ddd !important; box-shadow: none !important; }
646
+ a { color: #111 !important; text-decoration: underline; border-bottom: 0 !important; }
647
+ }
648
+ </style>
649
+ </head>
650
+ <body>
651
+ ${body}
652
+ <script>
653
+ (function(){
654
+ function apply(cfg){
655
+ if (!cfg) return;
656
+ if (cfg.theme === "light" || cfg.theme === "dark") {
657
+ document.documentElement.setAttribute("data-theme", cfg.theme);
658
+ window.__claudeDisplayTheme = cfg.theme;
659
+ }
660
+ if (cfg.preset === "paper" || cfg.preset === "aurora" || cfg.preset === "slate") {
661
+ document.documentElement.setAttribute("data-preset", cfg.preset);
662
+ window.__claudeDisplayPreset = cfg.preset;
663
+ }
664
+ if (cfg.density === "carded" || cfg.density === "flat") {
665
+ document.documentElement.setAttribute("data-density", cfg.density);
666
+ window.__claudeDisplayDensity = cfg.density;
667
+ }
668
+ }
669
+ window.addEventListener("message", function(e){
670
+ if (!e || !e.data) return;
671
+ if (e.data.type === "easel:config") apply(e.data);
672
+ if (e.data.type === "easel:theme") apply({ theme: e.data.theme });
673
+ if (e.data.type === "easel:print") {
674
+ try { window.print(); } catch(_) {}
675
+ }
676
+ if (e.data.type === "easel:image") {
677
+ var pushId = e.data.pushId;
678
+ var filename = e.data.filename || "push.png";
679
+ var bgColor =
680
+ e.data.bgColor ||
681
+ getComputedStyle(document.documentElement).getPropertyValue("--ds-bg-elev").trim() ||
682
+ "#ffffff";
683
+ function render() {
684
+ if (!window.htmlToImage) {
685
+ console.error("[easel] html-to-image not loaded");
686
+ return;
687
+ }
688
+ // Capture the html root at full viewport width so fixed/absolute
689
+ // positioning resolves the same as on screen. Capturing body alone
690
+ // honours max-width:auto-margins and breaks fixed elements that
691
+ // anchor to the viewport (modals at left:50% etc).
692
+ var width = document.documentElement.clientWidth;
693
+ var height = Math.max(
694
+ document.documentElement.scrollHeight,
695
+ document.body ? document.body.scrollHeight : 0,
696
+ );
697
+ window.htmlToImage.toPng(document.documentElement, {
698
+ backgroundColor: bgColor,
699
+ pixelRatio: 4,
700
+ cacheBust: true,
701
+ width: width,
702
+ height: height,
703
+ }).then(function(dataUrl){
704
+ parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename }, "*");
705
+ }).catch(function(err){
706
+ console.error("[easel] export failed", err);
707
+ });
708
+ }
709
+ if (document.fonts && document.fonts.ready) {
710
+ document.fonts.ready.then(render).catch(render);
711
+ } else { render(); }
712
+ }
713
+ });
714
+ })();
715
+ </script>
716
+ <script>${selfMeasureScript(pushId)}</script>
717
+ </body>
718
+ </html>`;
719
+ }
720
+
721
+ function injectBridge(html, theme, preset, pushId) {
722
+ const density = currentDensity();
723
+ const configScript =
724
+ "<script src='https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js'></script><script>(function(){function a(c){if(!c)return;if(c.theme==='light'||c.theme==='dark'){document.documentElement.setAttribute('data-theme',c.theme);window.__claudeDisplayTheme=c.theme}if(c.preset==='paper'||c.preset==='aurora'||c.preset==='slate'){document.documentElement.setAttribute('data-preset',c.preset);window.__claudeDisplayPreset=c.preset}if(c.density==='carded'||c.density==='flat'){document.documentElement.setAttribute('data-density',c.density);window.__claudeDisplayDensity=c.density}}a(" +
725
+ JSON.stringify({ theme, preset, density }) +
726
+ ");window.addEventListener('message',function(e){if(!e||!e.data)return;if(e.data.type==='easel:config')a(e.data);if(e.data.type==='easel:theme')a({theme:e.data.theme});if(e.data.type==='easel:print'){try{window.print()}catch(_){}}if(e.data.type==='easel:image'){var pid=e.data.pushId;var fn=e.data.filename||'push.png';var bg=e.data.bgColor||'#ffffff';if(!window.htmlToImage)return;window.htmlToImage.toPng(document.body,{backgroundColor:bg,pixelRatio:4,cacheBust:true}).then(function(u){parent.postMessage({type:'easel:image-ready',pushId:pid,dataUrl:u,filename:fn},'*')}).catch(function(err){console.error(err)})}})})();</script>";
727
+ const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
728
+ const combined = configScript + measureScript;
729
+ if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
730
+ return html + combined;
731
+ }
732
+
733
+ /* ============================================================
734
+ Feed updates
735
+ ============================================================ */
736
+
737
+ function appendPush(push, opts) {
738
+ const wasNearBottom = nearBottom();
739
+ const card = renderPush(push, opts);
740
+ cardsEl.appendChild(card);
741
+ setPushCount(totalPushes + 1);
742
+ // requestAnimationFrame so layout settles before pill recomputes.
743
+ requestAnimationFrame(updatePill);
744
+ return { wasNearBottom, card };
745
+ }
746
+
747
+ function removeCardFromDom(pushId) {
748
+ const card = document.getElementById("push-" + pushId);
749
+ if (card) card.remove();
750
+ const obs = cardObservers.get(pushId);
751
+ if (obs) {
752
+ obs.disconnect();
753
+ cardObservers.delete(pushId);
754
+ }
755
+ unreadIds.delete(pushId);
756
+ totalPushes = Math.max(0, totalPushes - 1);
757
+ setPushCount(totalPushes);
758
+ updatePill();
759
+ }
760
+
761
+ function bumpUnread(pushId) {
762
+ if (pushId && !unreadIds.has(pushId)) {
763
+ unreadIds.add(pushId);
764
+ bumpAt = Date.now();
765
+ observeUnreadCard(pushId);
766
+ }
767
+ updatePill();
768
+ }
769
+
770
+ /* One observer per unread card. The card counts as 'read' when its top
771
+ edge crosses into the upper half of the viewport — works regardless
772
+ of card height (the earlier 0.4 ratio threshold broke for cards
773
+ taller than the viewport, where 40% intersection is unreachable).
774
+
775
+ rootMargin "0px 0px -50% 0px" shrinks the bottom 50% of viewport from
776
+ the observer's root, so the card only intersects when ANY part of it
777
+ reaches the top half.
778
+
779
+ Bursts of layout-shift from iframe self-measure are still ignored
780
+ for 900ms after the most recent bump so resize-anchoring scrolls
781
+ don't spuriously mark cards read. */
782
+ function observeUnreadCard(pushId) {
783
+ const card = document.getElementById("push-" + pushId);
784
+ if (!card) return;
785
+ if (cardObservers.has(pushId)) {
786
+ cardObservers.get(pushId).disconnect();
787
+ }
788
+ const obs = new IntersectionObserver(
789
+ (entries) => {
790
+ if (Date.now() - bumpAt < 900) return;
791
+ if (entries.some((e) => e.isIntersecting)) {
792
+ markCardRead(pushId);
793
+ }
794
+ },
795
+ { threshold: 0, rootMargin: "0px 0px -50% 0px" },
796
+ );
797
+ obs.observe(card);
798
+ cardObservers.set(pushId, obs);
799
+ }
800
+
801
+ function markCardRead(pushId) {
802
+ if (!unreadIds.has(pushId)) return;
803
+ unreadIds.delete(pushId);
804
+ const obs = cardObservers.get(pushId);
805
+ if (obs) {
806
+ obs.disconnect();
807
+ cardObservers.delete(pushId);
808
+ }
809
+ updatePill();
810
+ }
811
+
812
+ /* True when the LATEST card's header is still below the viewport — i.e.
813
+ you haven't reached it yet. Once the header crosses in (or past), you're
814
+ inside/past the latest card and the 'Scroll to last' pill should hide. */
815
+ function lastCardHeaderBelowViewport() {
816
+ const last = cardsEl.lastElementChild;
817
+ if (!last) return false;
818
+ const rect = last.getBoundingClientRect();
819
+ return rect.top > window.innerHeight - 100;
820
+ }
821
+
822
+ /* Pill state machine:
823
+ - any unread → "N new push(es)", click → oldest unread
824
+ - none, last header still below → "Scroll to last", click → last card
825
+ - none, last header reached → hidden */
826
+ function updatePill() {
827
+ const n = unreadIds.size;
828
+ if (n > 0) {
829
+ newPillEl.hidden = false;
830
+ newPillEl.dataset.mode = "unread";
831
+ newPillText.textContent =
832
+ n + " new push" + (n === 1 ? "" : "es");
833
+ } else if (lastCardHeaderBelowViewport()) {
834
+ newPillEl.hidden = false;
835
+ newPillEl.dataset.mode = "scroll";
836
+ newPillText.textContent = "Scroll to last";
837
+ } else {
838
+ newPillEl.hidden = true;
839
+ delete newPillEl.dataset.mode;
840
+ }
841
+ }
842
+
843
+ function scrollToFirstUnread(smooth) {
844
+ const first = [...unreadIds][0]; // Set preserves insertion order
845
+ if (!first) {
846
+ scrollToLatestCard(smooth);
847
+ return;
848
+ }
849
+ const card = document.getElementById("push-" + first);
850
+ if (!card) {
851
+ scrollToLatestCard(smooth);
852
+ return;
853
+ }
854
+ card.scrollIntoView({
855
+ behavior: smooth ? "smooth" : "auto",
856
+ block: "start",
857
+ });
858
+ }
859
+
860
+ newPillEl.addEventListener("click", () => {
861
+ if (newPillEl.dataset.mode === "unread") {
862
+ scrollToFirstUnread(true);
863
+ } else {
864
+ scrollToLatestCard(true);
865
+ }
866
+ });
867
+
868
+ window.addEventListener("scroll", updatePill, { passive: true });
869
+ window.addEventListener("resize", updatePill);
870
+
871
+ // Clearing is driven by IntersectionObserver, not scroll events —
872
+ // resize-anchoring scrolls used to spuriously reset the counter.
873
+
874
+ /* ============================================================
875
+ Hydrate + SSE
876
+ ============================================================ */
877
+
878
+ async function hydrate() {
879
+ try {
880
+ const r = await fetch("/s/" + sessionId + "/state");
881
+ const view = await r.json();
882
+ setPrunedCount((view.meta && view.meta.prunedCount) || 0);
883
+ cardsEl.innerHTML = "";
884
+ iframes.clear();
885
+ setPushCount(0);
886
+ for (const p of view.pushes || []) {
887
+ appendPush(p, { fresh: false });
888
+ }
889
+ requestAnimationFrame(() => {
890
+ scrollToBottom(false);
891
+ updatePill();
892
+ });
893
+ } catch (err) {
894
+ console.error("[easel] hydrate failed", err);
895
+ }
896
+ }
897
+
898
+ function connectSse() {
899
+ const es = new EventSource("/s/" + sessionId + "/events");
900
+ es.addEventListener("hello", (e) => {
901
+ updateLive(true);
902
+ try {
903
+ const data = JSON.parse(e.data);
904
+ if (data && data.config) {
905
+ applyConfig(data.config, { skipServer: true });
906
+ }
907
+ } catch {}
908
+ });
909
+ es.addEventListener("config", (e) => {
910
+ try {
911
+ const cfg = JSON.parse(e.data);
912
+ applyConfig(cfg, { skipServer: true });
913
+ } catch {}
914
+ });
915
+ es.addEventListener("remove", (e) => {
916
+ try {
917
+ const data = JSON.parse(e.data);
918
+ if (data && data.pushId) removeCardFromDom(data.pushId);
919
+ } catch {}
920
+ });
921
+ es.addEventListener("push", (e) => {
922
+ try {
923
+ const push = JSON.parse(e.data);
924
+ const { wasNearBottom } = appendPush(push, { fresh: true });
925
+ if (wasNearBottom) {
926
+ requestAnimationFrame(() => scrollToLatestCard(true));
927
+ } else {
928
+ bumpUnread(push.id);
929
+ }
930
+ } catch (err) {
931
+ console.error("[easel] bad push payload", err);
932
+ }
933
+ });
934
+ es.onerror = () => {
935
+ updateLive(false);
936
+ es.close();
937
+ setTimeout(connectSse, 1500);
938
+ };
939
+ }
940
+
941
+ /* ============================================================
942
+ Last-visited tracking + session switcher dropdown
943
+ ============================================================ */
944
+
945
+ function markVisited() {
946
+ try {
947
+ const map = JSON.parse(localStorage.getItem(LAST_VISITED_KEY) || "{}");
948
+ map[sessionId] = Date.now();
949
+ localStorage.setItem(LAST_VISITED_KEY, JSON.stringify(map));
950
+ } catch (e) {
951
+ /* ignore */
952
+ }
953
+ }
954
+
955
+ function basenameOf(p) {
956
+ if (!p) return null;
957
+ const t = String(p).replace(/\/+$/, "");
958
+ const i = t.lastIndexOf("/");
959
+ return i >= 0 ? t.slice(i + 1) : t;
960
+ }
961
+
962
+ function relTime(ts) {
963
+ if (!ts) return "—";
964
+ const diff = Date.now() - ts;
965
+ if (diff < 30_000) return "just now";
966
+ const s = Math.floor(diff / 1000);
967
+ if (s < 60) return s + "s";
968
+ const m = Math.floor(s / 60);
969
+ if (m < 60) return m + "m";
970
+ const h = Math.floor(m / 60);
971
+ if (h < 24) return h + "h";
972
+ return Math.floor(h / 24) + "d";
973
+ }
974
+
975
+ async function loadSessionsForSwitcher() {
976
+ try {
977
+ const r = await fetch("/api/sessions");
978
+ const data = await r.json();
979
+ renderSwitcher(data.sessions || []);
980
+ } catch (e) {
981
+ renderSwitcher([]);
982
+ }
983
+ }
984
+
985
+ let switcherSessions = [];
986
+ let switcherQuery = "";
987
+
988
+ function renderSwitcher(sessions) {
989
+ switcherSessions = sessions || [];
990
+ switcherMenuEl.innerHTML = "";
991
+
992
+ // Search box
993
+ const searchBar = document.createElement("div");
994
+ searchBar.className = "switcher-search";
995
+ const search = document.createElement("input");
996
+ search.type = "search";
997
+ search.placeholder = "Filter by project or id…";
998
+ search.value = switcherQuery;
999
+ search.addEventListener("input", (e) => {
1000
+ switcherQuery = e.target.value;
1001
+ renderSwitcherList();
1002
+ });
1003
+ searchBar.appendChild(search);
1004
+ switcherMenuEl.appendChild(searchBar);
1005
+
1006
+ // Scrollable list
1007
+ const list = document.createElement("div");
1008
+ list.className = "switcher-list";
1009
+ list.id = "switcher-list";
1010
+ switcherMenuEl.appendChild(list);
1011
+
1012
+ // Footer
1013
+ const footerbar = document.createElement("div");
1014
+ footerbar.className = "switcher-footerbar";
1015
+ const allLink = document.createElement("a");
1016
+ allLink.href = "/";
1017
+ allLink.className = "switcher-footer";
1018
+ allLink.textContent = "View all sessions →";
1019
+ footerbar.appendChild(allLink);
1020
+ switcherMenuEl.appendChild(footerbar);
1021
+
1022
+ renderSwitcherList();
1023
+ // Focus the search input when the menu opens.
1024
+ setTimeout(() => search.focus(), 30);
1025
+ }
1026
+
1027
+ function renderSwitcherList() {
1028
+ const list = switcherMenuEl.querySelector("#switcher-list");
1029
+ if (!list) return;
1030
+ list.innerHTML = "";
1031
+
1032
+ let lastVisited = {};
1033
+ try {
1034
+ lastVisited = JSON.parse(localStorage.getItem(LAST_VISITED_KEY) || "{}");
1035
+ } catch {}
1036
+
1037
+ const q = switcherQuery.trim().toLowerCase();
1038
+ const filtered = switcherSessions.filter((s) => {
1039
+ if (!q) return true;
1040
+ const name = (basenameOf(s.cwd) || "").toLowerCase();
1041
+ return name.includes(q) || s.id.toLowerCase().includes(q);
1042
+ });
1043
+
1044
+ if (filtered.length === 0) {
1045
+ const empty = document.createElement("div");
1046
+ empty.className = "switcher-footer";
1047
+ empty.textContent = q ? "No sessions match." : "No sessions registered.";
1048
+ list.appendChild(empty);
1049
+ return;
1050
+ }
1051
+
1052
+ for (const s of filtered) {
1053
+ const item = document.createElement("a");
1054
+ item.className = "switcher-item";
1055
+ if (s.id === sessionId) item.classList.add("current");
1056
+ item.href = "/s/" + encodeURIComponent(s.id);
1057
+
1058
+ const project = document.createElement("span");
1059
+ project.className = "project";
1060
+ project.textContent = s.label || basenameOf(s.cwd) || s.id.slice(0, 8);
1061
+ project.title = [s.label, s.cwd, s.id].filter(Boolean).join(" · ");
1062
+ item.appendChild(project);
1063
+
1064
+ const count = document.createElement("span");
1065
+ count.className = "count";
1066
+ count.textContent = s.pushCount + " · " + relTime(s.lastActivity);
1067
+ item.appendChild(count);
1068
+
1069
+ if (
1070
+ s.id !== sessionId &&
1071
+ s.pushCount > 0 &&
1072
+ s.lastActivity > (lastVisited[s.id] || 0)
1073
+ ) {
1074
+ const dot = document.createElement("span");
1075
+ dot.className = "unread-dot";
1076
+ item.appendChild(dot);
1077
+ }
1078
+
1079
+ // Keep the brand label in sync with the current session.
1080
+ if (s.id === sessionId && projectLabelEl && !projectLabelEl.dataset.editing) {
1081
+ projectLabelEl.textContent =
1082
+ s.label || basenameOf(s.cwd) || "easel";
1083
+ projectLabelEl.dataset.cwd = s.cwd || "";
1084
+ projectLabelEl.dataset.hasLabel = s.label ? "true" : "false";
1085
+ }
1086
+
1087
+ // Delete button (skip on the current session — can't delete the one you're viewing)
1088
+ if (s.id !== sessionId) {
1089
+ const del = document.createElement("button");
1090
+ del.className = "switcher-del";
1091
+ del.type = "button";
1092
+ del.title = "Delete this session";
1093
+ del.setAttribute("aria-label", "Delete session");
1094
+ del.innerHTML =
1095
+ '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 4h9M6 4V2.5h4V4M5 4l.5 9a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1L11 4"/></svg>';
1096
+ del.addEventListener("click", async (e) => {
1097
+ e.preventDefault();
1098
+ e.stopPropagation();
1099
+ const name = s.label || basenameOf(s.cwd) || s.id.slice(0, 8);
1100
+ if (!window.confirm(`Delete session "${name}" and all its pushes?`)) return;
1101
+ await fetch("/api/sessions/" + encodeURIComponent(s.id), {
1102
+ method: "DELETE",
1103
+ });
1104
+ loadSessionsForSwitcher();
1105
+ });
1106
+ item.appendChild(del);
1107
+ }
1108
+
1109
+ list.appendChild(item);
1110
+ }
1111
+ }
1112
+
1113
+ switcherBtnEl.addEventListener("click", (e) => {
1114
+ e.stopPropagation();
1115
+ const open = switcherMenuEl.hidden;
1116
+ if (open) {
1117
+ switcherMenuEl.hidden = false;
1118
+ switcherBtnEl.setAttribute("aria-expanded", "true");
1119
+ loadSessionsForSwitcher();
1120
+ } else {
1121
+ switcherMenuEl.hidden = true;
1122
+ switcherBtnEl.setAttribute("aria-expanded", "false");
1123
+ }
1124
+ });
1125
+
1126
+ document.addEventListener("click", (e) => {
1127
+ if (!switcherMenuEl.hidden && !switcherMenuEl.contains(e.target) && e.target !== switcherBtnEl) {
1128
+ closeSwitcher();
1129
+ }
1130
+ });
1131
+
1132
+ // Clicks inside sandboxed iframes don't bubble — but they steal window
1133
+ // focus. When that happens, close the menu.
1134
+ window.addEventListener("blur", () => {
1135
+ if (!switcherMenuEl.hidden) {
1136
+ // Defer one tick so the focus change settles and the activeElement check
1137
+ // is reliable across browsers.
1138
+ setTimeout(() => {
1139
+ if (document.activeElement && document.activeElement.tagName === "IFRAME") {
1140
+ closeSwitcher();
1141
+ }
1142
+ }, 0);
1143
+ }
1144
+ });
1145
+
1146
+ function closeSwitcher() {
1147
+ switcherMenuEl.hidden = true;
1148
+ switcherBtnEl.setAttribute("aria-expanded", "false");
1149
+ }
1150
+
1151
+ /* === Click-to-edit session label in the topbar === */
1152
+ if (projectLabelEl) {
1153
+ projectLabelEl.style.cursor = "text";
1154
+ projectLabelEl.title = "Click to rename this session";
1155
+ projectLabelEl.addEventListener("click", startEditingLabel);
1156
+ }
1157
+
1158
+ function startEditingLabel() {
1159
+ if (projectLabelEl.dataset.editing) return;
1160
+ projectLabelEl.dataset.editing = "true";
1161
+ const current =
1162
+ projectLabelEl.dataset.hasLabel === "true"
1163
+ ? projectLabelEl.textContent
1164
+ : "";
1165
+ const input = document.createElement("input");
1166
+ input.type = "text";
1167
+ input.className = "project-label-input";
1168
+ input.value = current;
1169
+ input.placeholder = basenameOf(projectLabelEl.dataset.cwd) || "Name this session…";
1170
+ input.maxLength = 80;
1171
+
1172
+ projectLabelEl.replaceWith(input);
1173
+ input.focus();
1174
+ input.select();
1175
+
1176
+ let done = false;
1177
+ const finish = async (save) => {
1178
+ if (done) return;
1179
+ done = true;
1180
+ input.removeEventListener("blur", onBlur);
1181
+ input.removeEventListener("keydown", onKeydown);
1182
+ const newLabel = save ? input.value.trim() : current;
1183
+ input.replaceWith(projectLabelEl);
1184
+ delete projectLabelEl.dataset.editing;
1185
+ projectLabelEl.textContent =
1186
+ newLabel || basenameOf(projectLabelEl.dataset.cwd) || "easel";
1187
+ projectLabelEl.dataset.hasLabel = newLabel ? "true" : "false";
1188
+ if (save) {
1189
+ try {
1190
+ await fetch("/api/register", {
1191
+ method: "POST",
1192
+ headers: { "content-type": "application/json" },
1193
+ body: JSON.stringify({ sessionId, label: newLabel }),
1194
+ });
1195
+ } catch {}
1196
+ loadSessionsForSwitcher();
1197
+ }
1198
+ };
1199
+ const onBlur = () => finish(true);
1200
+ const onKeydown = (e) => {
1201
+ if (e.key === "Enter") finish(true);
1202
+ if (e.key === "Escape") finish(false);
1203
+ };
1204
+ input.addEventListener("blur", onBlur);
1205
+ input.addEventListener("keydown", onKeydown);
1206
+ }
1207
+
1208
+ markVisited();
1209
+ window.addEventListener("focus", markVisited);
1210
+ setInterval(markVisited, 15_000);
1211
+ loadSessionsForSwitcher();
1212
+ setInterval(loadSessionsForSwitcher, 8_000);
1213
+
1214
+ hydrate().then(connectSse);
1215
+ })();