@ammduncan/easel 0.3.1 → 0.3.3
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 +14 -0
- package/dist/client/viewer.js +96 -67
- package/dist/mcp.js +9 -0
- package/dist/session-id.js +30 -18
- package/package.json +2 -1
- package/skills/using-easel/SKILL.md +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 0.3.3 — 2026-05-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **PNG and PDF export no longer hang when the easel tab isn't the visible foreground tab.** Export rasterised via `html-to-image`'s `toPng`/`toJpeg`, which resolve through the library's internal `createImage()` — and that waits on `requestAnimationFrame`. Chrome freezes rAF in hidden/background tabs, so the rasterize promise never settled: click export, switch back to your terminal, and the button spinner span forever with no error (the viewer had no timeout to recover). Both formats died here because they share the path. The export now stops at `htmlToImage.toSvg()` (no rAF) and rasterises onto a canvas with a plain `Image`, whose `onload` fires even in hidden tabs. Quality is unchanged — the SVG is vector, drawn onto a DPR-4 canvas, so PNG stays lossless and PDF stays JPEG q1.0. The two render paths (`buildDefaultWrapper` and the full-HTML `injectBridge`, which had drifted to capturing `body` vs `documentElement`) now share one `imageExportScript()`. A missing `html-to-image` now posts an error instead of returning silently, and a 30s parent-side watchdog clears the spinner and surfaces a timeout if the iframe never reports back. Covered by `tests/unit/image-export.test.mjs`.
|
|
9
|
+
|
|
10
|
+
## 0.3.2 — 2026-05-26
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **App-fidelity (`kind:"mockup"`/`"app"`) text painted with `light-dark()` now tracks the easel light/dark toggle instead of the OS color scheme.** Authors paint mockup ink with `color: light-dark(#dark-ink, #light-ink)`, which resolves off the document's *computed* `color-scheme` — not the `data-theme` attribute. The normal wrapper binds `color-scheme` to `data-theme` via `PRESET_TOKENS_CSS`, so `light-dark()` follows the toggle there. But the app-fidelity branch deliberately omits the preset tokens (the agent owns every pixel) and nothing else bound `color-scheme`, so it stayed at the author's `color-scheme: light dark` and `light-dark()` followed the **OS** preference. The symptom was intermittent and maddening: text was perfectly readable when the viewer's theme happened to match the OS, then washed out (white ink on a light card, or dark ink on a dark card) the moment they disagreed. Added a `:root[data-theme]{color-scheme}` binding to the shared `STRUCTURAL_PRIMITIVES_CSS` so `light-dark()` tracks the easel toggle in *every* wrapper branch. A new visual-regression fixture (`mockup-lightdark-ink`) audited across the theme × OS-scheme matrix reproduces the washout with the binding removed (contrast 1.0) and passes with it in place.
|
|
14
|
+
- **Session-id resolution no longer drifts for non-Claude-Code MCP clients (opencode, Cursor, Windsurf, …).** The resolver's tier-4 fallback scanned `~/.claude/projects/<cwd>/` for the most-recently-modified transcript. That's a Claude-Code-specific signal, but it fired for *any* client — so a non-CC client running in a cwd that also holds Claude Code transcripts latched onto whichever transcript was touched last, and the resolved session id changed on every tool call (observed in opencode: `open()`, `push()`, and `label()` each landing on a different session). The scan is now gated behind a positive Claude Code signal (`CLAUDECODE` / `CLAUDE_CODE_ENTRYPOINT`); other clients fall straight through to the stable per-process synthetic id (tier 5), so `open`/`push`/`label` all resolve to one session for the life of the chat. Covered by a new unit suite (`tests/unit/session-id.test.mjs`, run via `npm test`).
|
|
15
|
+
|
|
16
|
+
### Docs
|
|
17
|
+
- **`push` tool description and the using-easel skill now lead with a fidelity bar: ship high-fidelity, production-grade output by default.** Aimed at getting quality output from non-Claude models that don't infer it — every push should read like a screenshot of shipped software (real content, complete regions, exact values when recreating UI, real iconography, deliberate hierarchy), not a wireframe or grey-box. Low-fidelity is opt-in: only when the user explicitly says rough/wireframe/sketch is fine.
|
|
18
|
+
|
|
5
19
|
## 0.3.1 — 2026-05-26
|
|
6
20
|
|
|
7
21
|
### Fixed
|
package/dist/client/viewer.js
CHANGED
|
@@ -7,6 +7,27 @@
|
|
|
7
7
|
const cardsEl = document.getElementById("cards");
|
|
8
8
|
const emptyEl = document.getElementById("empty-state");
|
|
9
9
|
const countEl = document.getElementById("push-count");
|
|
10
|
+
|
|
11
|
+
// Per-push export watchdog timers. If the iframe never posts back
|
|
12
|
+
// image-ready / image-error (e.g. a render stall), the watchdog clears the
|
|
13
|
+
// button spinner and surfaces an error instead of spinning forever.
|
|
14
|
+
const EXPORT_TIMEOUT_MS = 30000;
|
|
15
|
+
const exportWatchdogs = new Map();
|
|
16
|
+
function clearExportSpinner(pushId) {
|
|
17
|
+
const iframeEl = cardsEl.querySelector(
|
|
18
|
+
'iframe[data-push-id="' + cssEscape(pushId) + '"]',
|
|
19
|
+
);
|
|
20
|
+
const push = iframeEl && iframeEl.closest(".push");
|
|
21
|
+
const ex = push && push.querySelector(".push-export");
|
|
22
|
+
if (ex) delete ex.dataset.loading;
|
|
23
|
+
}
|
|
24
|
+
function clearExportWatchdog(pushId) {
|
|
25
|
+
const t = exportWatchdogs.get(pushId);
|
|
26
|
+
if (t) {
|
|
27
|
+
clearTimeout(t);
|
|
28
|
+
exportWatchdogs.delete(pushId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
10
31
|
const prunedEl = document.getElementById("pruned-marker");
|
|
11
32
|
const liveDotEl = document.getElementById("live-dot");
|
|
12
33
|
const liveLabelEl = document.getElementById("live-label");
|
|
@@ -120,6 +141,15 @@
|
|
|
120
141
|
block, so stripping these in fidelity mode left the skill's own guidance
|
|
121
142
|
("wrap a mockup in .window") producing unstyled output. */
|
|
122
143
|
const STRUCTURAL_PRIMITIVES_CSS = `
|
|
144
|
+
/* Bind the CSS color-scheme to the host theme so any author CSS that uses
|
|
145
|
+
light-dark() (text ink, surfaces, borders) tracks the easel light/dark
|
|
146
|
+
TOGGLE rather than the OS preference. The default wrapper already gets this
|
|
147
|
+
via PRESET_TOKENS_CSS, but app-fidelity (kind:"mockup") pushes omit the
|
|
148
|
+
preset tokens — without this rule their light-dark() ink follows the OS
|
|
149
|
+
scheme and washes out whenever the OS disagrees with the easel toggle. */
|
|
150
|
+
:root[data-theme="light"] { color-scheme: light; }
|
|
151
|
+
:root[data-theme="dark"] { color-scheme: dark; }
|
|
152
|
+
|
|
123
153
|
/* Skeuomorphic macOS-style window chrome for UI mockups. Usage:
|
|
124
154
|
<div class="window" data-title="App name"> …mockup content… </div>
|
|
125
155
|
Draws a 40px title bar with the three traffic-light dots (red/yellow/green)
|
|
@@ -267,27 +297,15 @@
|
|
|
267
297
|
}
|
|
268
298
|
if (data.type === "easel:image-error") {
|
|
269
299
|
console.error("[easel] iframe export error", data);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
);
|
|
273
|
-
if (iframeEl && iframeEl.closest(".push")) {
|
|
274
|
-
const ex = iframeEl.closest(".push").querySelector(".push-export");
|
|
275
|
-
if (ex) delete ex.dataset.loading;
|
|
276
|
-
}
|
|
300
|
+
clearExportWatchdog(data.pushId);
|
|
301
|
+
clearExportSpinner(data.pushId);
|
|
277
302
|
alert("Export failed (" + (data.format || "?") + "): " + (data.message || "unknown"));
|
|
278
303
|
return;
|
|
279
304
|
}
|
|
280
305
|
if (data.type === "easel:image-ready") {
|
|
281
306
|
const format = data.format === "pdf" ? "pdf" : "png";
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
|
|
285
|
-
);
|
|
286
|
-
if (iframeEl && iframeEl.closest(".push")) {
|
|
287
|
-
const ex = iframeEl.closest(".push").querySelector(".push-export");
|
|
288
|
-
if (ex) delete ex.dataset.loading;
|
|
289
|
-
}
|
|
290
|
-
};
|
|
307
|
+
clearExportWatchdog(data.pushId);
|
|
308
|
+
const clearLoading = () => clearExportSpinner(data.pushId);
|
|
291
309
|
|
|
292
310
|
if (format === "pdf") {
|
|
293
311
|
downloadAsPdf(data.dataUrl, data.filename || "push.pdf")
|
|
@@ -645,6 +663,20 @@
|
|
|
645
663
|
function requestExport(format) {
|
|
646
664
|
exportBtn.dataset.loading = "true";
|
|
647
665
|
|
|
666
|
+
clearExportWatchdog(push.id);
|
|
667
|
+
exportWatchdogs.set(
|
|
668
|
+
push.id,
|
|
669
|
+
setTimeout(() => {
|
|
670
|
+
exportWatchdogs.delete(push.id);
|
|
671
|
+
clearExportSpinner(push.id);
|
|
672
|
+
alert(
|
|
673
|
+
"Export timed out after " +
|
|
674
|
+
EXPORT_TIMEOUT_MS / 1000 +
|
|
675
|
+
"s. Try again with the easel tab in the foreground.",
|
|
676
|
+
);
|
|
677
|
+
}, EXPORT_TIMEOUT_MS),
|
|
678
|
+
);
|
|
679
|
+
|
|
648
680
|
// Match the export bg to what the user sees inside this card:
|
|
649
681
|
// carded → card's elevated surface (--ds-bg-elev)
|
|
650
682
|
// flat → page canvas (--ds-bg) since the iframe body is transparent
|
|
@@ -667,6 +699,7 @@
|
|
|
667
699
|
"*",
|
|
668
700
|
);
|
|
669
701
|
} catch (err) {
|
|
702
|
+
clearExportWatchdog(push.id);
|
|
670
703
|
delete exportBtn.dataset.loading;
|
|
671
704
|
console.error("[easel] export failed", err);
|
|
672
705
|
}
|
|
@@ -760,6 +793,49 @@
|
|
|
760
793
|
);
|
|
761
794
|
}
|
|
762
795
|
|
|
796
|
+
/**
|
|
797
|
+
* In-iframe message listener that turns the rendered push into a PNG/JPEG
|
|
798
|
+
* dataURL on `easel:image` and posts back `easel:image-ready` / `-error`.
|
|
799
|
+
*
|
|
800
|
+
* Deliberately stops at htmlToImage.toSvg() and does the canvas rasterisation
|
|
801
|
+
* by hand. toPng/toJpeg/toCanvas resolve via the library's internal
|
|
802
|
+
* createImage(), which waits on requestAnimationFrame — and Chrome freezes
|
|
803
|
+
* rAF in hidden/background tabs, so the export hung forever whenever the
|
|
804
|
+
* easel tab wasn't the visible one. toSvg has no rAF, and a plain Image's
|
|
805
|
+
* onload fires even in hidden tabs, so this path works regardless of tab
|
|
806
|
+
* visibility. Quality is unchanged: the SVG is vector, drawn onto a
|
|
807
|
+
* PIXEL_RATIO-scaled canvas → still crisp at DPR 4.
|
|
808
|
+
*
|
|
809
|
+
* Shared verbatim by buildDefaultWrapper and injectBridge so the two render
|
|
810
|
+
* paths can't drift.
|
|
811
|
+
*/
|
|
812
|
+
function imageExportScript() {
|
|
813
|
+
return (
|
|
814
|
+
"(function(){var PR=4;" +
|
|
815
|
+
"function fail(id,fmt,err){console.error('[easel] export failed',err);" +
|
|
816
|
+
"parent.postMessage({type:'easel:image-error',pushId:id,format:fmt,message:(err&&err.message)?err.message:String(err)},'*')}" +
|
|
817
|
+
"function rasterize(svgUrl,w,h,bg,fmt){return new Promise(function(resolve,reject){" +
|
|
818
|
+
"var img=new Image();" +
|
|
819
|
+
"img.onload=function(){try{var c=document.createElement('canvas');" +
|
|
820
|
+
"c.width=Math.max(1,Math.round(w*PR));c.height=Math.max(1,Math.round(h*PR));" +
|
|
821
|
+
"var x=c.getContext('2d');x.fillStyle=bg;x.fillRect(0,0,c.width,c.height);" +
|
|
822
|
+
"x.drawImage(img,0,0,c.width,c.height);" +
|
|
823
|
+
"resolve(fmt==='pdf'?c.toDataURL('image/jpeg',1.0):c.toDataURL('image/png'))}catch(e){reject(e)}};" +
|
|
824
|
+
"img.onerror=function(){reject(new Error('SVG snapshot failed to load'))};img.src=svgUrl})}" +
|
|
825
|
+
"function run(d){var id=d.pushId;var fn=d.filename||'push.png';var fmt=d.format==='pdf'?'pdf':'png';" +
|
|
826
|
+
"var bg=d.bgColor||getComputedStyle(document.documentElement).getPropertyValue('--ds-bg-elev').trim()||'#ffffff';" +
|
|
827
|
+
"function render(){if(!window.htmlToImage){fail(id,fmt,new Error('html-to-image not loaded'));return}" +
|
|
828
|
+
"var w=document.documentElement.clientWidth;" +
|
|
829
|
+
"var h=Math.max(document.documentElement.scrollHeight,document.body?document.body.scrollHeight:0);" +
|
|
830
|
+
"window.htmlToImage.toSvg(document.documentElement,{width:w,height:h,cacheBust:true})" +
|
|
831
|
+
".then(function(u){return rasterize(u,w,h,bg,fmt)})" +
|
|
832
|
+
".then(function(u){parent.postMessage({type:'easel:image-ready',pushId:id,dataUrl:u,filename:fn,format:fmt},'*')})" +
|
|
833
|
+
".catch(function(e){fail(id,fmt,e)})}" +
|
|
834
|
+
"if(document.fonts&&document.fonts.ready){document.fonts.ready.then(render).catch(render)}else{render()}}" +
|
|
835
|
+
"window.addEventListener('message',function(e){if(e&&e.data&&e.data.type==='easel:image')run(e.data)})})();"
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
763
839
|
function buildDefaultWrapper(body, theme, preset, pushId, appFidelity) {
|
|
764
840
|
const density = currentDensity();
|
|
765
841
|
// app-fidelity mode: skip presentation defaults (presets, semantic chips,
|
|
@@ -995,58 +1071,10 @@ ${body}
|
|
|
995
1071
|
if (e.data.type === "easel:print") {
|
|
996
1072
|
try { window.print(); } catch(_) {}
|
|
997
1073
|
}
|
|
998
|
-
if (e.data.type === "easel:image") {
|
|
999
|
-
var pushId = e.data.pushId;
|
|
1000
|
-
var filename = e.data.filename || "push.png";
|
|
1001
|
-
var format = e.data.format === "pdf" ? "pdf" : "png";
|
|
1002
|
-
var bgColor =
|
|
1003
|
-
e.data.bgColor ||
|
|
1004
|
-
getComputedStyle(document.documentElement).getPropertyValue("--ds-bg-elev").trim() ||
|
|
1005
|
-
"#ffffff";
|
|
1006
|
-
function render() {
|
|
1007
|
-
if (!window.htmlToImage) {
|
|
1008
|
-
console.error("[easel] html-to-image not loaded");
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
// Capture the html root at full viewport width so fixed/absolute
|
|
1012
|
-
// positioning resolves the same as on screen. Capturing body alone
|
|
1013
|
-
// honours max-width:auto-margins and breaks fixed elements that
|
|
1014
|
-
// anchor to the viewport (modals at left:50% etc).
|
|
1015
|
-
var width = document.documentElement.clientWidth;
|
|
1016
|
-
var height = Math.max(
|
|
1017
|
-
document.documentElement.scrollHeight,
|
|
1018
|
-
document.body ? document.body.scrollHeight : 0,
|
|
1019
|
-
);
|
|
1020
|
-
// PNG target → lossless PNG @ pixelRatio 4 for crisp standalone files.
|
|
1021
|
-
// PDF target → JPEG @ quality 1.0 + pixelRatio 4. PDFs natively use
|
|
1022
|
-
// DCT compression for embedded JPEGs, so even at max quality the PDF
|
|
1023
|
-
// stays in the ~3-8 MB range for a typical card (vs ~300 MB if we
|
|
1024
|
-
// embedded as PNG — see 0.2.15). 1.0 + DPR 4 keeps text razor-sharp
|
|
1025
|
-
// at any zoom level; tuned down to 0.92 + DPR 2 in 0.2.15 dropped to
|
|
1026
|
-
// ~800 KB but at the cost of visible JPEG artefacts on type.
|
|
1027
|
-
var rasterFn = format === "pdf" ? window.htmlToImage.toJpeg : window.htmlToImage.toPng;
|
|
1028
|
-
var rasterOpts = {
|
|
1029
|
-
backgroundColor: bgColor,
|
|
1030
|
-
pixelRatio: 4,
|
|
1031
|
-
cacheBust: true,
|
|
1032
|
-
width: width,
|
|
1033
|
-
height: height,
|
|
1034
|
-
};
|
|
1035
|
-
if (format === "pdf") rasterOpts.quality = 1.0;
|
|
1036
|
-
rasterFn(document.documentElement, rasterOpts).then(function(dataUrl){
|
|
1037
|
-
parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename, format: format }, "*");
|
|
1038
|
-
}).catch(function(err){
|
|
1039
|
-
console.error("[easel] export failed", err);
|
|
1040
|
-
parent.postMessage({ type: "easel:image-error", pushId: pushId, format: format, message: (err && err.message) ? err.message : String(err) }, "*");
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1043
|
-
if (document.fonts && document.fonts.ready) {
|
|
1044
|
-
document.fonts.ready.then(render).catch(render);
|
|
1045
|
-
} else { render(); }
|
|
1046
|
-
}
|
|
1047
1074
|
});
|
|
1048
1075
|
})();
|
|
1049
1076
|
</script>
|
|
1077
|
+
<script>${imageExportScript()}</script>
|
|
1050
1078
|
<script>${selfMeasureScript(pushId)}</script>
|
|
1051
1079
|
</body>
|
|
1052
1080
|
</html>`;
|
|
@@ -1057,9 +1085,10 @@ ${body}
|
|
|
1057
1085
|
const configScript =
|
|
1058
1086
|
"<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(" +
|
|
1059
1087
|
JSON.stringify({ theme, preset, density }) +
|
|
1060
|
-
");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(_){}}
|
|
1088
|
+
");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(_){}}})})();</script>";
|
|
1089
|
+
const imageScript = "<script>" + imageExportScript() + "</script>";
|
|
1061
1090
|
const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
|
|
1062
|
-
const combined = configScript + measureScript;
|
|
1091
|
+
const combined = configScript + imageScript + measureScript;
|
|
1063
1092
|
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
|
|
1064
1093
|
return html + combined;
|
|
1065
1094
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -102,6 +102,15 @@ export async function main() {
|
|
|
102
102
|
{
|
|
103
103
|
name: TOOL_PUSH,
|
|
104
104
|
description: "Push an HTML card to this session's live browser tab. Renders in a sandboxed iframe over a host-controlled canvas that can be LIGHT or DARK depending on the user's OS theme. Treat each card as a presentation slide — generous whitespace, presentation-scale type, tangible visuals. Your HTML MUST adapt to both light and dark modes.\n\n" +
|
|
105
|
+
"═══ FIDELITY BAR — SHIP HIGH-FIDELITY BY DEFAULT ═══\n" +
|
|
106
|
+
"Default to polished, production-grade output that looks like a real screenshot of shipped software or a finished design — NOT a rough sketch, wireframe, or grey-box placeholder. This is the default for EVERY push; you do not need to be asked for quality. Only drop to low-fidelity (wireframe, ASCII-ish boxes, lorem-ipsum, unstyled) when the user EXPLICITLY says rough/lo-fi/wireframe/sketch/quick-and-dirty is fine, or asks for a thumbnail/napkin idea. When in doubt, go high-fidelity.\n" +
|
|
107
|
+
"What high-fidelity means concretely:\n" +
|
|
108
|
+
"• REAL content, not placeholders. Plausible names, realistic numbers/dates/currency, actual copy — never 'Lorem ipsum', 'Label', 'Item 1 / Item 2', 'Title goes here', or '...'.\n" +
|
|
109
|
+
"• COMPLETE, not stubbed. Fill every region you draw — no empty cells, half-built tables, or 'etc.' rows. If a screen has 8 nav items, draw 8.\n" +
|
|
110
|
+
"• EXACT values when recreating real UI — pull true colors, spacing, radii, type, and layout from the component/theme/Figma/DevTools (see the recreation rules below). A close-but-wrong mock misleads more than none.\n" +
|
|
111
|
+
"• Visual craft: deliberate hierarchy, aligned grids, consistent spacing scale, real iconography (inline SVG, not emoji-as-icon), proper empty/hover/active states where they matter. Avoid the generic-AI look (one purple gradient, evenly-sized boxes, centered everything).\n" +
|
|
112
|
+
"• Tangible over abstract (see VISUALS): a mock should read as the actual thing, not labeled rectangles.\n" +
|
|
113
|
+
"If you genuinely can't reach the bar (missing real values, ambiguous source), say so in ONE line in chat and push your best honest attempt — don't pass a rough draft off as final, and don't silently ship a grey-box.\n\n" +
|
|
105
114
|
"═══ ADAPTIVE COLOR (gets wrong most often) ═══\n" +
|
|
106
115
|
"• Do NOT set `background` on `body` or your root wrapper. The host paints the canvas — setting bg fights it and creates a wrong-shade block in the opposite mode.\n" +
|
|
107
116
|
"• Use `light-dark()` for ALL text colors, card backgrounds, borders, and decorative shades. Add `:root { color-scheme: light dark; }` so the function resolves. Hardcoded `color: #475569` goes invisible in dark mode; hardcoded `border: 1px solid #e5e5e5` becomes a hard white line.\n" +
|
package/dist/session-id.js
CHANGED
|
@@ -10,7 +10,11 @@ import { HOOK_DIR } from "./paths.js";
|
|
|
10
10
|
* 3. Hook file at ~/.easel/hook/cc-session-<ppid>.txt (Claude Code's
|
|
11
11
|
* SessionStart hook writes this; pitstop-style PPID bridging)
|
|
12
12
|
* 4. Most-recently-modified transcript under ~/.claude/projects/<cwd>/
|
|
13
|
-
* (Claude Code transcript scan)
|
|
13
|
+
* (Claude Code transcript scan) — ONLY when a positive Claude Code env
|
|
14
|
+
* signal is present. Other MCP clients (opencode, Cursor, Windsurf, …)
|
|
15
|
+
* can share a cwd that already holds CC transcripts; without this guard
|
|
16
|
+
* they'd latch onto whichever unrelated transcript was touched last and
|
|
17
|
+
* the resolved session would drift on every tool call.
|
|
14
18
|
* 5. Synthetic id derived from this MCP child's PPID — gives every other
|
|
15
19
|
* MCP client (Cursor, Windsurf, Claude Desktop, etc.) a stable session
|
|
16
20
|
* per chat without requiring any hook. The MCP child IS the session.
|
|
@@ -38,25 +42,33 @@ export function resolveClaudeSessionId(opts = {}) {
|
|
|
38
42
|
catch {
|
|
39
43
|
/* fall through */
|
|
40
44
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
// Tier 4 (transcript scan) is Claude-Code-specific: only trust it when we
|
|
46
|
+
// have positive evidence we're actually running inside Claude Code. For any
|
|
47
|
+
// other MCP client the scan would pick an unrelated, actively-changing CC
|
|
48
|
+
// transcript in the same cwd and the session id would drift per call — so
|
|
49
|
+
// skip straight to the stable per-process synthetic id (tier 5).
|
|
50
|
+
const isClaudeCode = Boolean(env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT);
|
|
51
|
+
if (isClaudeCode) {
|
|
52
|
+
try {
|
|
53
|
+
const encoded = cwd.replace(/\//g, "-");
|
|
54
|
+
const dir = join(home, ".claude", "projects", encoded);
|
|
55
|
+
let bestId;
|
|
56
|
+
let bestMtime = 0;
|
|
57
|
+
for (const f of readdirSync(dir)) {
|
|
58
|
+
if (!f.endsWith(".jsonl"))
|
|
59
|
+
continue;
|
|
60
|
+
const m = statSync(join(dir, f)).mtimeMs;
|
|
61
|
+
if (m > bestMtime) {
|
|
62
|
+
bestMtime = m;
|
|
63
|
+
bestId = f.slice(0, -".jsonl".length);
|
|
64
|
+
}
|
|
53
65
|
}
|
|
66
|
+
if (bestId)
|
|
67
|
+
return bestId;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* fall through */
|
|
54
71
|
}
|
|
55
|
-
if (bestId)
|
|
56
|
-
return bestId;
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
/* fall through */
|
|
60
72
|
}
|
|
61
73
|
return syntheticSessionIdFromPpid(ppid);
|
|
62
74
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ammduncan/easel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"build": "tsc && node scripts/copy-client.mjs",
|
|
44
44
|
"start": "node dist/cli.js",
|
|
45
45
|
"dev": "tsc --watch",
|
|
46
|
+
"test": "npm run build && node --test tests/unit/*.test.mjs",
|
|
46
47
|
"prepublishOnly": "npm run build"
|
|
47
48
|
},
|
|
48
49
|
"publishConfig": {
|
|
@@ -49,6 +49,20 @@ Don't poll. Just react to the hint when it appears.
|
|
|
49
49
|
|
|
50
50
|
Pushed cards are **presentations**, not UI dashboards. Read each rule and apply it; the wrapper gives you good defaults but they only carry so far.
|
|
51
51
|
|
|
52
|
+
### 0. Fidelity bar — ship high-fidelity by default
|
|
53
|
+
|
|
54
|
+
Every push should look like a **screenshot of shipped software or a finished design** — polished and production-grade — not a rough sketch, wireframe, or grey-box placeholder. Quality is the default; you don't need to be asked for it. Only drop to low-fidelity when the user **explicitly** says rough / lo-fi / wireframe / sketch / quick-and-dirty is fine, or asks for a napkin-level thumbnail. When unsure, go high-fidelity.
|
|
55
|
+
|
|
56
|
+
Concretely, high-fidelity means:
|
|
57
|
+
|
|
58
|
+
- **Real content, never placeholders.** Plausible names, realistic numbers/dates/currency, actual copy — no "Lorem ipsum", "Label", "Item 1 / Item 2", "Title goes here", or "…".
|
|
59
|
+
- **Complete, not stubbed.** Fill every region you draw — no empty cells, half-built tables, or "etc." rows. A nav with 8 items shows 8.
|
|
60
|
+
- **Exact values when recreating real UI.** Pull true colors, spacing, radii, type, and layout from the component / theme / Figma / DevTools (see [Use the actual values](#use-the-actual-values-not-approximations)). A close-but-wrong mock misleads more than none.
|
|
61
|
+
- **Visual craft.** Deliberate hierarchy, aligned grids, a consistent spacing scale, real iconography (inline SVG — not emoji standing in for icons), and the states that matter (empty / hover / active). Avoid the generic-AI look: one purple gradient, evenly-sized boxes, everything centered.
|
|
62
|
+
- **Tangible over abstract** (see [Visualizations](#5-visualizations--tangible-over-abstract)) — the mock should read as the actual thing, not labeled rectangles.
|
|
63
|
+
|
|
64
|
+
If you genuinely can't clear the bar (missing real values, ambiguous source), say so in one line in chat and push your best honest attempt — don't pass a rough draft off as final, and don't silently ship a grey-box.
|
|
65
|
+
|
|
52
66
|
### 1. Typography
|
|
53
67
|
|
|
54
68
|
- **Page lede**: 40–52 px, weight 500, letter-spacing ≈ -0.025em
|