@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 +25 -0
- package/dist/client/index.css +6 -8
- package/dist/client/viewer.css +54 -4
- package/dist/client/viewer.html +1 -0
- package/dist/client/viewer.js +164 -20
- package/dist/http-server.js +7 -1
- package/dist/mcp.js +50 -12
- package/package.json +1 -1
- package/skills/using-easel/SKILL.md +18 -0
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
|
package/dist/client/index.css
CHANGED
|
@@ -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 {
|
|
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);
|
package/dist/client/viewer.css
CHANGED
|
@@ -588,17 +588,67 @@ body {
|
|
|
588
588
|
.push-export[data-loading] {
|
|
589
589
|
opacity: 1 !important;
|
|
590
590
|
color: var(--ds-accent);
|
|
591
|
-
|
|
591
|
+
pointer-events: none;
|
|
592
592
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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;
|
package/dist/client/viewer.html
CHANGED
|
@@ -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 () {
|
package/dist/client/viewer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = "
|
|
370
|
-
exportBtn.setAttribute("aria-label", "
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
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
|
-
|
|
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>");
|
package/dist/http-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
267
|
-
|
|
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.
|
|
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.
|