@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 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
@@ -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
- const iframeEl = cardsEl.querySelector(
271
- 'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
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
- const clearLoading = () => {
283
- const iframeEl = cardsEl.querySelector(
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(_){}}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;var rfn=fmt==='pdf'?window.htmlToImage.toJpeg:window.htmlToImage.toPng;var ropts={backgroundColor:bg,pixelRatio:4,cacheBust:true};if(fmt==='pdf')ropts.quality=1.0;rfn(document.body,ropts).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>";
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" +
@@ -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
- try {
42
- const encoded = cwd.replace(/\//g, "-");
43
- const dir = join(home, ".claude", "projects", encoded);
44
- let bestId;
45
- let bestMtime = 0;
46
- for (const f of readdirSync(dir)) {
47
- if (!f.endsWith(".jsonl"))
48
- continue;
49
- const m = statSync(join(dir, f)).mtimeMs;
50
- if (m > bestMtime) {
51
- bestMtime = m;
52
- bestId = f.slice(0, -".jsonl".length);
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.1",
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