@ammduncan/easel 0.2.8 → 0.2.11

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/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 0.2.11 — 2026-05-22
6
+
7
+ ### Docs
8
+ - **Locked-mode container guidance now covers syntax highlighting explicitly.** The existing rule ("background and text are a pair — commit both, re-scope `color: inherit` to children") only addressed single-color text. Syntax-highlighted code blocks layer multiple token colors on top of that, and the recurring failure was a `property` / `punctuation` / `comment` token colored near-background (e.g. `#2c2c40` on `#0f172a`) silently rendering whole identifiers invisible. New guidance in `skills/using-easel/SKILL.md` and the inline `push` tool description spells out: use a tested theme designed for your bg (Shiki `github-dark` / `vitesse-dark` / `one-dark-pro` for `#0f172a`-ish; `github-light` / `vitesse-light` for `#f5f7fa`-ish) OR pick from a verified 6-color palette for `#0f172a` (keyword `#ff7b72`, string `#a5d6ff`, function `#d2a8ff`, property `#79c0ff`, number `#ffa657`, comment `#8b949e`, default `#e6edf3`). Fallback: drop highlighting, use single-color monospace.
9
+
10
+ ## 0.2.10 — 2026-05-22
11
+
12
+ ### Changed
13
+ - **Auto-open moved from "first tool call" to "first push", and is now aware of which sessions are currently being viewed.** The previous behaviour fired on the very first tool call (often `label` or `config`) and used a binary "have we attempted ever?" guard, which meant agents calling `label` before any push would burn the attempt without opening, and parent orchestrators that never push would still trigger a tab. New decision tree:
14
+ - `sessionTabs > 0` (this session is being viewed in ≥1 tab) → silent push, no auto-open.
15
+ - `otherTabs > 0` (easel is being viewed but on a different session) → no auto-open; the push response now hints to the agent to ask the user whether to switch to this session via the topbar `switch ▾` dropdown or call `open` for a new window. Avoids surprising users who deliberately switched their existing easel tab to look at another session.
16
+ - both `0` and no hook-managed tab → auto-open one tab.
17
+ - Still one-shot per MCP child lifetime — if the user closes the tab after the first auto-open, subsequent pushes don't re-open; the response hints they can call `open` to force one.
18
+ - Explicit `open` calls also mark the attempt as taken, so an `open` followed by a manual close is respected the same way.
19
+ - Server's `/api/push` response now returns `otherTabs` alongside `sessionTabs`.
20
+
21
+ ## 0.2.9 — 2026-05-22
22
+
23
+ ### Added
24
+ - **Per-push download menu with PNG and PDF.** The download icon on each push card now opens a small popover anchored under the icon, offering PNG (existing flow) or PDF. PDF mode reuses the same `html-to-image` rasterise we already run for PNG, then embeds the resulting bitmap into a `jsPDF` document sized to the canvas dimensions — one continuous page, no pagination, regardless of card height. jsPDF loads from the same jsDelivr CDN we already use for `html-to-image`.
25
+ - **Real loading state on the download icon.** Replaces the previous subtle pulse — during render the icon swaps to a spinning ring and the button is click-locked until the export finishes (PNG anchor download or jsPDF save). Errors from the iframe-side rasterise now propagate to the parent via a new `easel:image-error` message and surface as an alert so silent hangs are visible.
26
+
27
+ ### Fixed
28
+ - **Switcher delete button no longer overlaps the push count / timestamp.** The trash icon was absolute-positioned at `right: 36px` and sat on top of `.count` on hover. Moved into the flex flow as a sibling after `.count`, with `visibility: hidden` reserving its 22px slot so the count text stays clear at all times.
29
+
5
30
  ## 0.2.8 — 2026-05-22
6
31
 
7
32
  ### Docs
@@ -76,17 +76,11 @@
76
76
  opacity: 1 !important;
77
77
  }
78
78
 
79
- .switcher-item {
80
- position: relative;
81
- }
82
79
  .switcher-del {
83
- position: absolute;
84
- top: 50%;
85
- right: 36px;
86
- transform: translateY(-50%);
87
80
  display: inline-flex;
88
81
  align-items: center;
89
82
  justify-content: center;
83
+ flex-shrink: 0;
90
84
  width: 22px;
91
85
  height: 22px;
92
86
  background: transparent;
@@ -94,10 +88,14 @@
94
88
  border-radius: 6px;
95
89
  color: var(--ds-muted);
96
90
  cursor: pointer;
91
+ visibility: hidden;
97
92
  opacity: 0;
98
93
  transition: opacity 120ms ease, background 120ms ease, color 120ms ease;
99
94
  }
100
- .switcher-item:hover .switcher-del { opacity: 0.65; }
95
+ .switcher-item:hover .switcher-del {
96
+ visibility: visible;
97
+ opacity: 0.65;
98
+ }
101
99
  .switcher-del:hover {
102
100
  background: var(--ds-surface-soft);
103
101
  color: var(--ds-danger);
@@ -588,17 +588,67 @@ body {
588
588
  .push-export[data-loading] {
589
589
  opacity: 1 !important;
590
590
  color: var(--ds-accent);
591
- animation: easel-pulse 1.2s ease-in-out infinite;
591
+ pointer-events: none;
592
592
  }
593
- @keyframes easel-pulse {
594
- 0%, 100% { opacity: 0.55; }
595
- 50% { opacity: 1; }
593
+ .push-export .push-export-spinner {
594
+ display: none;
595
+ }
596
+ .push-export[data-loading] .push-export-icon {
597
+ display: none;
598
+ }
599
+ .push-export[data-loading] .push-export-spinner {
600
+ display: block;
601
+ animation: easel-spin 0.7s linear infinite;
602
+ }
603
+ @keyframes easel-spin {
604
+ to { transform: rotate(360deg); }
596
605
  }
597
606
  .push-del svg,
598
607
  .push-export svg {
599
608
  display: block;
600
609
  }
601
610
 
611
+ .push-export-wrap {
612
+ position: relative;
613
+ display: inline-flex;
614
+ }
615
+ .push-export-menu {
616
+ position: absolute;
617
+ top: calc(100% + 4px);
618
+ right: 0;
619
+ z-index: 30;
620
+ display: flex;
621
+ flex-direction: column;
622
+ min-width: 96px;
623
+ padding: 4px;
624
+ background: var(--ds-bg-elev);
625
+ border: 1px solid var(--ds-border, rgba(0, 0, 0, 0.12));
626
+ border-radius: 8px;
627
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
628
+ }
629
+ .push-export-menu[hidden] {
630
+ display: none;
631
+ }
632
+ .push-export-menu button {
633
+ appearance: none;
634
+ display: block;
635
+ width: 100%;
636
+ padding: 6px 10px;
637
+ background: transparent;
638
+ border: 0;
639
+ border-radius: 6px;
640
+ font: inherit;
641
+ font-size: 12px;
642
+ text-align: left;
643
+ color: var(--ds-ink);
644
+ cursor: pointer;
645
+ }
646
+ .push-export-menu button:hover,
647
+ .push-export-menu button:focus-visible {
648
+ background: var(--ds-surface-soft);
649
+ outline: none;
650
+ }
651
+
602
652
  .push-body {
603
653
  background: var(--ds-bg-elev);
604
654
  overflow: hidden;
@@ -8,6 +8,7 @@
8
8
  <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
9
9
  <link rel="stylesheet" href="/static/viewer.css" />
10
10
  <link rel="stylesheet" href="/static/index.css" />
11
+ <script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js"></script>
11
12
  <script>
12
13
  // Bootstrap theme before paint to avoid flash.
13
14
  (function () {
@@ -144,21 +144,48 @@
144
144
  }
145
145
  return;
146
146
  }
147
+ if (data.type === "easel:image-error") {
148
+ console.error("[easel] iframe export error", data);
149
+ const iframeEl = cardsEl.querySelector(
150
+ 'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
151
+ );
152
+ if (iframeEl && iframeEl.closest(".push")) {
153
+ const ex = iframeEl.closest(".push").querySelector(".push-export");
154
+ if (ex) delete ex.dataset.loading;
155
+ }
156
+ alert("Export failed (" + (data.format || "?") + "): " + (data.message || "unknown"));
157
+ return;
158
+ }
147
159
  if (data.type === "easel:image-ready") {
148
- // Iframe rasterised itself; trigger a download in the parent.
160
+ const format = data.format === "pdf" ? "pdf" : "png";
161
+ const clearLoading = () => {
162
+ const iframeEl = cardsEl.querySelector(
163
+ 'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
164
+ );
165
+ if (iframeEl && iframeEl.closest(".push")) {
166
+ const ex = iframeEl.closest(".push").querySelector(".push-export");
167
+ if (ex) delete ex.dataset.loading;
168
+ }
169
+ };
170
+
171
+ if (format === "pdf") {
172
+ downloadAsPdf(data.dataUrl, data.filename || "push.pdf")
173
+ .catch((err) => {
174
+ console.error("[easel] pdf export failed", err);
175
+ alert("PDF export failed: " + (err && err.message ? err.message : err));
176
+ })
177
+ .finally(clearLoading);
178
+ return;
179
+ }
180
+
181
+ // PNG — direct anchor download.
149
182
  const a = document.createElement("a");
150
183
  a.href = data.dataUrl;
151
184
  a.download = data.filename || "push.png";
152
185
  document.body.appendChild(a);
153
186
  a.click();
154
187
  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
- }
188
+ clearLoading();
162
189
  return;
163
190
  }
164
191
  });
@@ -168,6 +195,62 @@
168
195
  return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
169
196
  }
170
197
 
198
+ /**
199
+ * Embed a PNG dataURL into a single-page PDF sized to the image's pixel
200
+ * dimensions, producing a continuous (no page-breaks) document, then save.
201
+ */
202
+ function downloadAsPdf(dataUrl, filename) {
203
+ return new Promise((resolve, reject) => {
204
+ const jspdfNs = window.jspdf;
205
+ if (!jspdfNs || !jspdfNs.jsPDF) {
206
+ reject(new Error("jsPDF not loaded"));
207
+ return;
208
+ }
209
+ const img = new Image();
210
+ img.onload = () => {
211
+ try {
212
+ const w = img.naturalWidth || img.width;
213
+ const h = img.naturalHeight || img.height;
214
+ const pdf = new jspdfNs.jsPDF({
215
+ unit: "px",
216
+ format: [w, h],
217
+ orientation: w > h ? "landscape" : "portrait",
218
+ hotfixes: ["px_scaling"],
219
+ });
220
+ pdf.addImage(dataUrl, "PNG", 0, 0, w, h);
221
+ pdf.save(filename);
222
+ resolve();
223
+ } catch (err) {
224
+ reject(err);
225
+ }
226
+ };
227
+ img.onerror = () => reject(new Error("image decode failed"));
228
+ img.src = dataUrl;
229
+ });
230
+ }
231
+
232
+ // Dismiss any open push-export menu on outside click / Escape.
233
+ document.addEventListener("click", (e) => {
234
+ if (e.target.closest && e.target.closest(".push-export-wrap")) return;
235
+ document.querySelectorAll(".push-export-menu").forEach((m) => {
236
+ if (!m.hidden) {
237
+ m.hidden = true;
238
+ const sib = m.previousElementSibling;
239
+ if (sib) sib.setAttribute("aria-expanded", "false");
240
+ }
241
+ });
242
+ });
243
+ document.addEventListener("keydown", (e) => {
244
+ if (e.key !== "Escape") return;
245
+ document.querySelectorAll(".push-export-menu").forEach((m) => {
246
+ if (!m.hidden) {
247
+ m.hidden = true;
248
+ const sib = m.previousElementSibling;
249
+ if (sib) sib.setAttribute("aria-expanded", "false");
250
+ }
251
+ });
252
+ });
253
+
171
254
  /* ============================================================
172
255
  Theming
173
256
  ============================================================ */
@@ -363,21 +446,75 @@
363
446
  time.textContent = formatTime(push.createdAt);
364
447
  meta.appendChild(time);
365
448
 
449
+ const exportWrap = document.createElement("div");
450
+ exportWrap.className = "push-export-wrap";
451
+
366
452
  const exportBtn = document.createElement("button");
367
453
  exportBtn.className = "push-export";
368
454
  exportBtn.type = "button";
369
- exportBtn.title = "Save as PNG";
370
- exportBtn.setAttribute("aria-label", "Save push as PNG");
455
+ exportBtn.title = "Download";
456
+ exportBtn.setAttribute("aria-label", "Download this push");
457
+ exportBtn.setAttribute("aria-haspopup", "menu");
458
+ exportBtn.setAttribute("aria-expanded", "false");
371
459
  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)
460
+ '<svg class="push-export-icon" 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>' +
461
+ '<svg class="push-export-spinner" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="9" stroke-opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9"/></svg>';
462
+
463
+ const exportMenu = document.createElement("div");
464
+ exportMenu.className = "push-export-menu";
465
+ exportMenu.setAttribute("role", "menu");
466
+ exportMenu.hidden = true;
467
+ exportMenu.innerHTML =
468
+ '<button type="button" role="menuitem" data-format="png">PNG</button>' +
469
+ '<button type="button" role="menuitem" data-format="pdf">PDF</button>';
470
+
471
+ const safeTitle = () =>
472
+ (push.title || "push-" + push.index)
377
473
  .toLowerCase()
378
474
  .replace(/[^a-z0-9-]+/g, "-")
379
475
  .replace(/^-+|-+$/g, "")
380
476
  .slice(0, 60) || "push";
477
+
478
+ function closeExportMenu() {
479
+ if (exportMenu.hidden) return;
480
+ exportMenu.hidden = true;
481
+ exportBtn.setAttribute("aria-expanded", "false");
482
+ }
483
+
484
+ function openExportMenu() {
485
+ // Close any other open export menus first.
486
+ cardsEl.querySelectorAll(".push-export-menu").forEach((m) => {
487
+ if (m !== exportMenu) {
488
+ m.hidden = true;
489
+ const sib = m.previousElementSibling;
490
+ if (sib) sib.setAttribute("aria-expanded", "false");
491
+ }
492
+ });
493
+ exportMenu.hidden = false;
494
+ exportBtn.setAttribute("aria-expanded", "true");
495
+ }
496
+
497
+ exportBtn.addEventListener("click", (e) => {
498
+ e.stopPropagation();
499
+ e.preventDefault();
500
+ if (exportMenu.hidden) {
501
+ openExportMenu();
502
+ } else {
503
+ closeExportMenu();
504
+ }
505
+ });
506
+
507
+ exportMenu.addEventListener("click", (e) => {
508
+ const btn = e.target.closest("button[data-format]");
509
+ if (!btn) return;
510
+ e.stopPropagation();
511
+ e.preventDefault();
512
+ const format = btn.dataset.format === "pdf" ? "pdf" : "png";
513
+ closeExportMenu();
514
+ requestExport(format);
515
+ });
516
+
517
+ function requestExport(format) {
381
518
  exportBtn.dataset.loading = "true";
382
519
 
383
520
  // Match the export bg to what the user sees inside this card:
@@ -387,6 +524,7 @@
387
524
  const isFlat = currentDensity() === "flat";
388
525
  const bgVar = isFlat ? "--ds-bg" : "--ds-bg-elev";
389
526
  const bgColor = rootStyle.getPropertyValue(bgVar).trim() || "#ffffff";
527
+ const filename = safeTitle() + (format === "pdf" ? ".pdf" : ".png");
390
528
 
391
529
  try {
392
530
  iframe.contentWindow &&
@@ -394,7 +532,8 @@
394
532
  {
395
533
  type: "easel:image",
396
534
  pushId: push.id,
397
- filename: safeTitle + ".png",
535
+ filename,
536
+ format,
398
537
  bgColor,
399
538
  },
400
539
  "*",
@@ -403,8 +542,11 @@
403
542
  delete exportBtn.dataset.loading;
404
543
  console.error("[easel] export failed", err);
405
544
  }
406
- });
407
- meta.appendChild(exportBtn);
545
+ }
546
+
547
+ exportWrap.appendChild(exportBtn);
548
+ exportWrap.appendChild(exportMenu);
549
+ meta.appendChild(exportWrap);
408
550
 
409
551
  const del = document.createElement("button");
410
552
  del.className = "push-del";
@@ -676,6 +818,7 @@ ${body}
676
818
  if (e.data.type === "easel:image") {
677
819
  var pushId = e.data.pushId;
678
820
  var filename = e.data.filename || "push.png";
821
+ var format = e.data.format === "pdf" ? "pdf" : "png";
679
822
  var bgColor =
680
823
  e.data.bgColor ||
681
824
  getComputedStyle(document.documentElement).getPropertyValue("--ds-bg-elev").trim() ||
@@ -701,9 +844,10 @@ ${body}
701
844
  width: width,
702
845
  height: height,
703
846
  }).then(function(dataUrl){
704
- parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename }, "*");
847
+ parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename, format: format }, "*");
705
848
  }).catch(function(err){
706
849
  console.error("[easel] export failed", err);
850
+ parent.postMessage({ type: "easel:image-error", pushId: pushId, format: format, message: (err && err.message) ? err.message : String(err) }, "*");
707
851
  });
708
852
  }
709
853
  if (document.fonts && document.fonts.ready) {
@@ -723,7 +867,7 @@ ${body}
723
867
  const configScript =
724
868
  "<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
869
  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>";
870
+ ");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 fmt=e.data.format==='pdf'?'pdf':'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,format:fmt},'*')}).catch(function(err){console.error(err);parent.postMessage({type:'easel:image-error',pushId:pid,format:fmt,message:(err&&err.message)?err.message:String(err)},'*')})}})})();</script>";
727
871
  const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
728
872
  const combined = configScript + measureScript;
729
873
  if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
@@ -164,15 +164,21 @@ export function startHttpServer() {
164
164
  sweepIdleSessions();
165
165
  }
166
166
  let sessionTabs = 0;
167
+ let otherTabs = 0;
167
168
  for (const c of clients.values()) {
168
- if (c.sessionId === sessionId)
169
+ if (c.sessionId === sessionId) {
169
170
  sessionTabs++;
171
+ }
172
+ else {
173
+ otherTabs++;
174
+ }
170
175
  }
171
176
  res.json({
172
177
  url: `http://localhost:${port}/s/${sessionId}`,
173
178
  slide_id: push.id,
174
179
  index: push.index,
175
180
  sessionTabs,
181
+ otherTabs,
176
182
  });
177
183
  });
178
184
  const server = app.listen(port, "127.0.0.1", () => {
package/dist/mcp.js CHANGED
@@ -33,15 +33,41 @@ function hookHasFiredForThisPpid() {
33
33
  return existsSync(join(HOOK_DIR, `cc-session-${process.ppid}.txt`));
34
34
  }
35
35
  // One-shot guard: only auto-open once per MCP-child lifetime. If the user
36
- // closes the tab afterwards, subsequent pushes won't re-open it.
36
+ // closes the tab afterwards, subsequent pushes won't re-open it — the user
37
+ // closing the tab is treated as an explicit dismissal we should respect.
37
38
  let autoOpenAttempted = false;
38
- function maybeAutoOpenTab(url) {
39
+ /**
40
+ * Decide whether to auto-open the session URL on first push.
41
+ *
42
+ * - `sessionTabs > 0`: a tab is already showing THIS session → no-op.
43
+ * - `otherTabs > 0`: easel is open, but on a different session. Don't surprise
44
+ * the user with another window — return "other-session" so the caller can
45
+ * tell the agent to ask whether to use the topbar switcher or open a new tab.
46
+ * - both 0 + no hook-managed tab: auto-open one tab.
47
+ *
48
+ * One-shot per MCP child lifetime. Closing the tab counts as dismissal;
49
+ * subsequent pushes won't re-open.
50
+ */
51
+ function autoOpenIfNeeded(url, sessionTabs, otherTabs) {
39
52
  if (autoOpenAttempted)
40
- return;
53
+ return { kind: "noop" };
54
+ if (sessionTabs > 0) {
55
+ autoOpenAttempted = true;
56
+ return { kind: "noop" };
57
+ }
58
+ if (otherTabs > 0) {
59
+ // Easel is alive in another session — don't open a new window without asking.
60
+ autoOpenAttempted = true;
61
+ return { kind: "other-session" };
62
+ }
63
+ if (hookHasFiredForThisPpid()) {
64
+ // Claude Code's SessionStart hook handled it (or tried to).
65
+ autoOpenAttempted = true;
66
+ return { kind: "noop" };
67
+ }
41
68
  autoOpenAttempted = true;
42
- if (hookHasFiredForThisPpid())
43
- return; // Claude Code already opened it
44
69
  openUrlInBrowser(url);
70
+ return { kind: "opened" };
45
71
  }
46
72
  const inputSchema = {
47
73
  type: "object",
@@ -101,7 +127,8 @@ export async function main() {
101
127
  " .terminal { background: #0f172a; color: #e6edf3; border-radius: 12px; padding: 20px 24px; font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 13.5px; line-height: 1.7; }\n" +
102
128
  " .terminal *, .terminal span, .terminal pre { color: inherit; }\n" +
103
129
  " .terminal .muted { color: #94a3b8; }\n" +
104
- " .terminal .accent { color: #6ee7b7; }\n\n" +
130
+ " .terminal .accent { color: #6ee7b7; }\n" +
131
+ "• Syntax-highlighted code in a locked-bg block: EVERY token color must be verified readable against the bg, not just the body color. Recurring bug: locking to #0f172a then giving 'property' / 'punctuation' / 'comment' tokens something like #2c2c40 because it 'looked subtle' — against #0f172a it's nearly invisible and identifiers disappear. Either use a tested theme designed for your bg (Shiki github-dark / vitesse-dark / one-dark-pro for #0f172a-ish, github-light / vitesse-light for #f5f7fa-ish), or pick from this verified palette for #0f172a: keyword #ff7b72, string #a5d6ff, function #d2a8ff, property #79c0ff, number #ffa657, comment #8b949e, default text #e6edf3. If you can't articulate why each token reads against the bg, drop highlighting and use single-color monospace — that always works.\n\n" +
105
132
  "═══ TYPOGRAPHY (presentation scale, NOT dashboard) ═══\n" +
106
133
  "• Hero title: 44–52px, weight 500, letter-spacing -0.025em\n" +
107
134
  "• Section titles: 28–36px, weight 500\n" +
@@ -191,12 +218,12 @@ export async function main() {
191
218
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
192
219
  const sessionId = resolveClaudeSessionId();
193
220
  const { port } = await ensureHttpServer();
194
- // Non-Claude-Code clients have no SessionStart hook to open the tab —
195
- // we do it ourselves on the first tool call instead.
196
- maybeAutoOpenTab(`http://localhost:${port}/s/${sessionId}`);
197
221
  if (req.params.name === TOOL_OPEN) {
198
222
  const url = `http://localhost:${port}/s/${sessionId}`;
199
223
  openUrlInBrowser(url);
224
+ // Explicit open also counts as "we've opened it" — if the user closes
225
+ // this tab later, a subsequent push shouldn't auto-reopen.
226
+ autoOpenAttempted = true;
200
227
  return {
201
228
  content: [
202
229
  {
@@ -263,9 +290,20 @@ export async function main() {
263
290
  kind: args.kind,
264
291
  port,
265
292
  });
266
- const tabHint = result.sessionTabs === 0
267
- ? " · NO TAB OPEN for this session — ask the user if you should open one (call `open`)"
268
- : "";
293
+ const url = `http://localhost:${port}/s/${sessionId}`;
294
+ const openResult = autoOpenIfNeeded(url, result.sessionTabs, result.otherTabs);
295
+ let tabHint = "";
296
+ if (openResult.kind === "opened") {
297
+ tabHint = " · opened a tab for this session";
298
+ }
299
+ else if (openResult.kind === "other-session") {
300
+ tabHint =
301
+ " · easel is open in another tab on a different session — ASK the user whether to switch via the topbar 'switch ▾' dropdown to this session, or call `open` to launch a new tab/window for it";
302
+ }
303
+ else if (result.sessionTabs === 0) {
304
+ tabHint =
305
+ " · no tab open for this session — user previously closed it; call `open` to force a new one if needed";
306
+ }
269
307
  return {
270
308
  content: [
271
309
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ammduncan/easel",
3
- "version": "0.2.8",
3
+ "version": "0.2.11",
4
4
  "description": "A live browser tab for every Claude Code (and MCP) session. The push MCP tool appends HTML cards to a scrolling feed you keep open in split-screen.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -198,6 +198,24 @@ When the mockup references a real thing — a real app, a real component, a real
198
198
 
199
199
  The rule of thumb: background and text are a pair — commit one, commit the other.
200
200
 
201
+ **Syntax highlighting in locked-bg code blocks needs *every token* verified.** "Bg + text are a pair" extends to every token color you use. The recurring failure: lock a code block to `#0f172a`, then layer syntax tokens where one (usually `property`, `punctuation`, or `comment`) is colored `#2c2c40` or `#3b4252` because it "looked subtle" — against `#0f172a` it's nearly invisible and whole identifiers disappear from the block. Two ways out:
202
+
203
+ 1. **Use a tested theme designed for your bg.** Shiki / Prism / Highlight.js themes like `github-dark`, `vitesse-dark`, `one-dark-pro` for `#0f172a`-ish backgrounds; `github-light`, `vitesse-light` for `#f5f7fa`-ish. The theme's author already verified contrast — don't override individual tokens.
204
+ 2. **Hand-rolling tokens? Verify each one against the bg, or pick from this verified palette for `#0f172a`:**
205
+
206
+ ```css
207
+ .code { background: #0f172a; color: #e6edf3; }
208
+ .code .keyword { color: #ff7b72; } /* red-pink: keywords, control flow */
209
+ .code .string { color: #a5d6ff; } /* sky: strings, attribute values */
210
+ .code .function { color: #d2a8ff; } /* purple: function names */
211
+ .code .property { color: #79c0ff; } /* blue: identifiers, properties, members */
212
+ .code .number { color: #ffa657; } /* orange: numbers, constants */
213
+ .code .comment { color: #8b949e; } /* muted gray: comments — still readable */
214
+ .code * { color: inherit; } /* default everything else to body color */
215
+ ```
216
+
217
+ If you can't articulate why each token color reads against the bg, drop syntax highlighting entirely and use single-color monospace — that always works.
218
+
201
219
  ### 5. Visualizations — tangible over abstract
202
220
 
203
221
  When something can be represented as a real-world object — browser-chrome tab cards with red/yellow/green dots, proportional horizontal timeline bars with marked phases, device frames, code editor windows, terminal windows, merging-pipe shapes for funnels — **do that**, not abstract labeled rectangles with arrows.