@ammduncan/easel 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 0.4.0 — 2026-05-26
6
+
7
+ ### Added
8
+ - **Remote images are now inlined server-side at push time, so pushes that embed cross-origin images export correctly.** A push that referenced a remote image (e.g. a mobbin.com screenshot in an `app`/`mockup` recreation) rendered fine on screen but **couldn't be exported to PNG/PDF**: the browser blocks cross-origin images from canvas rasterisation (CORS), and drawing one taints the canvas so `toDataURL` throws — export would stall until the 30s watchdog fired (see 0.3.3). The fix moves the fetch to the server: `POST /api/push` now scans incoming HTML for remote `<img src>` and CSS `url(...)` references, fetches each server-side (no CORS limit), and rewrites them to self-contained `data:` URLs before storing the push. Exports then work, and the push survives the original URL later expiring (mobbin's `…/mcp/short/…` links are ephemeral). Fetches run in parallel, each bounded by an 8s timeout with an 8MB size cap and an image-content-type check; a URL that can't be inlined (timeout, non-image, too large, network error) is left untouched — it still displays, just won't appear in an export — so a dead link degrades gracefully instead of failing the push. Opt out with `EASEL_INLINE_IMAGES=0`. Covered by `tests/unit/inline-images.test.mjs`.
9
+
10
+ ## 0.3.3 — 2026-05-26
11
+
12
+ ### Fixed
13
+ - **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`.
14
+
5
15
  ## 0.3.2 — 2026-05-26
6
16
 
7
17
  ### 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");
@@ -276,27 +297,15 @@
276
297
  }
277
298
  if (data.type === "easel:image-error") {
278
299
  console.error("[easel] iframe export error", data);
279
- const iframeEl = cardsEl.querySelector(
280
- 'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
281
- );
282
- if (iframeEl && iframeEl.closest(".push")) {
283
- const ex = iframeEl.closest(".push").querySelector(".push-export");
284
- if (ex) delete ex.dataset.loading;
285
- }
300
+ clearExportWatchdog(data.pushId);
301
+ clearExportSpinner(data.pushId);
286
302
  alert("Export failed (" + (data.format || "?") + "): " + (data.message || "unknown"));
287
303
  return;
288
304
  }
289
305
  if (data.type === "easel:image-ready") {
290
306
  const format = data.format === "pdf" ? "pdf" : "png";
291
- const clearLoading = () => {
292
- const iframeEl = cardsEl.querySelector(
293
- 'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
294
- );
295
- if (iframeEl && iframeEl.closest(".push")) {
296
- const ex = iframeEl.closest(".push").querySelector(".push-export");
297
- if (ex) delete ex.dataset.loading;
298
- }
299
- };
307
+ clearExportWatchdog(data.pushId);
308
+ const clearLoading = () => clearExportSpinner(data.pushId);
300
309
 
301
310
  if (format === "pdf") {
302
311
  downloadAsPdf(data.dataUrl, data.filename || "push.pdf")
@@ -654,6 +663,20 @@
654
663
  function requestExport(format) {
655
664
  exportBtn.dataset.loading = "true";
656
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
+
657
680
  // Match the export bg to what the user sees inside this card:
658
681
  // carded → card's elevated surface (--ds-bg-elev)
659
682
  // flat → page canvas (--ds-bg) since the iframe body is transparent
@@ -676,6 +699,7 @@
676
699
  "*",
677
700
  );
678
701
  } catch (err) {
702
+ clearExportWatchdog(push.id);
679
703
  delete exportBtn.dataset.loading;
680
704
  console.error("[easel] export failed", err);
681
705
  }
@@ -769,6 +793,49 @@
769
793
  );
770
794
  }
771
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
+
772
839
  function buildDefaultWrapper(body, theme, preset, pushId, appFidelity) {
773
840
  const density = currentDensity();
774
841
  // app-fidelity mode: skip presentation defaults (presets, semantic chips,
@@ -1004,58 +1071,10 @@ ${body}
1004
1071
  if (e.data.type === "easel:print") {
1005
1072
  try { window.print(); } catch(_) {}
1006
1073
  }
1007
- if (e.data.type === "easel:image") {
1008
- var pushId = e.data.pushId;
1009
- var filename = e.data.filename || "push.png";
1010
- var format = e.data.format === "pdf" ? "pdf" : "png";
1011
- var bgColor =
1012
- e.data.bgColor ||
1013
- getComputedStyle(document.documentElement).getPropertyValue("--ds-bg-elev").trim() ||
1014
- "#ffffff";
1015
- function render() {
1016
- if (!window.htmlToImage) {
1017
- console.error("[easel] html-to-image not loaded");
1018
- return;
1019
- }
1020
- // Capture the html root at full viewport width so fixed/absolute
1021
- // positioning resolves the same as on screen. Capturing body alone
1022
- // honours max-width:auto-margins and breaks fixed elements that
1023
- // anchor to the viewport (modals at left:50% etc).
1024
- var width = document.documentElement.clientWidth;
1025
- var height = Math.max(
1026
- document.documentElement.scrollHeight,
1027
- document.body ? document.body.scrollHeight : 0,
1028
- );
1029
- // PNG target → lossless PNG @ pixelRatio 4 for crisp standalone files.
1030
- // PDF target → JPEG @ quality 1.0 + pixelRatio 4. PDFs natively use
1031
- // DCT compression for embedded JPEGs, so even at max quality the PDF
1032
- // stays in the ~3-8 MB range for a typical card (vs ~300 MB if we
1033
- // embedded as PNG — see 0.2.15). 1.0 + DPR 4 keeps text razor-sharp
1034
- // at any zoom level; tuned down to 0.92 + DPR 2 in 0.2.15 dropped to
1035
- // ~800 KB but at the cost of visible JPEG artefacts on type.
1036
- var rasterFn = format === "pdf" ? window.htmlToImage.toJpeg : window.htmlToImage.toPng;
1037
- var rasterOpts = {
1038
- backgroundColor: bgColor,
1039
- pixelRatio: 4,
1040
- cacheBust: true,
1041
- width: width,
1042
- height: height,
1043
- };
1044
- if (format === "pdf") rasterOpts.quality = 1.0;
1045
- rasterFn(document.documentElement, rasterOpts).then(function(dataUrl){
1046
- parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename, format: format }, "*");
1047
- }).catch(function(err){
1048
- console.error("[easel] export failed", err);
1049
- parent.postMessage({ type: "easel:image-error", pushId: pushId, format: format, message: (err && err.message) ? err.message : String(err) }, "*");
1050
- });
1051
- }
1052
- if (document.fonts && document.fonts.ready) {
1053
- document.fonts.ready.then(render).catch(render);
1054
- } else { render(); }
1055
- }
1056
1074
  });
1057
1075
  })();
1058
1076
  </script>
1077
+ <script>${imageExportScript()}</script>
1059
1078
  <script>${selfMeasureScript(pushId)}</script>
1060
1079
  </body>
1061
1080
  </html>`;
@@ -1066,9 +1085,10 @@ ${body}
1066
1085
  const configScript =
1067
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(" +
1068
1087
  JSON.stringify({ theme, preset, density }) +
1069
- ");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>";
1070
1090
  const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
1071
- const combined = configScript + measureScript;
1091
+ const combined = configScript + imageScript + measureScript;
1072
1092
  if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
1073
1093
  return html + combined;
1074
1094
  }
@@ -6,6 +6,7 @@ import { appendPush, deletePush, deleteSession, getSessionView, listSessionSumma
6
6
  import { readConfig, writeConfig } from "./config-store.js";
7
7
  import { clearLockIfMine, writeLock } from "./server-manager.js";
8
8
  import { resolvePort } from "./paths.js";
9
+ import { inlineRemoteImages } from "./inline-images.js";
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const CLIENT_DIR = resolve(__dirname, "client");
11
12
  const clients = new Map();
@@ -157,7 +158,7 @@ export function startHttpServer() {
157
158
  const ok = deleteSession(id);
158
159
  res.json({ ok });
159
160
  });
160
- app.post("/api/push", (req, res) => {
161
+ app.post("/api/push", async (req, res) => {
161
162
  const { sessionId, html, title, kind } = req.body ?? {};
162
163
  if (typeof sessionId !== "string" || !sessionId.trim()) {
163
164
  res.status(400).json({ error: "sessionId required" });
@@ -167,7 +168,24 @@ export function startHttpServer() {
167
168
  res.status(400).json({ error: "html required" });
168
169
  return;
169
170
  }
170
- const push = appendPush(sessionId, { html, title, kind });
171
+ // Inline remote images server-side so the stored push is self-contained
172
+ // and exportable (cross-origin images are CORS-blocked from client-side
173
+ // rasterisation). Best-effort: on any failure we store the original html.
174
+ let storedHtml = html;
175
+ if (process.env.EASEL_INLINE_IMAGES !== "0") {
176
+ try {
177
+ const result = await inlineRemoteImages(html);
178
+ storedHtml = result.html;
179
+ if (result.failed.length > 0) {
180
+ console.warn(`[easel] ${result.failed.length} remote image(s) left un-inlined (won't export): ` +
181
+ result.failed.map((f) => `${f.url} — ${f.reason}`).join("; "));
182
+ }
183
+ }
184
+ catch (err) {
185
+ console.warn("[easel] image inlining failed; storing original html:", err);
186
+ }
187
+ }
188
+ const push = appendPush(sessionId, { html: storedHtml, title, kind });
171
189
  touchSession(sessionId);
172
190
  broadcast(sessionId, "push", push);
173
191
  if (Math.random() < 0.05) {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Inline remote images into pushed HTML so exports work.
3
+ *
4
+ * Cross-origin images (e.g. mobbin screenshots) render on screen but can't be
5
+ * rasterised client-side: fetching them for inlining is CORS-blocked, and
6
+ * drawing a cross-origin image taints the canvas so PNG/PDF export throws. We
7
+ * fetch them server-side (no CORS) at push time and rewrite the references to
8
+ * self-contained `data:` URLs, so the stored push exports cleanly and survives
9
+ * the original URL later expiring.
10
+ *
11
+ * A remote URL that can't be inlined (timeout, non-image, too large, network
12
+ * error) is left untouched — it still displays cross-origin, it just won't
13
+ * appear in an export — so a dead link degrades gracefully instead of failing
14
+ * the push. Only `http(s)` URLs are touched; `data:`, `blob:`, and relative
15
+ * references are left alone.
16
+ */
17
+ const PER_IMAGE_TIMEOUT_MS = 8000;
18
+ const MAX_IMAGE_BYTES = 8_000_000;
19
+ // `src="https://…"` (img/source/…) and CSS `url(https://…)` with optional quotes.
20
+ // Capture group 2 is the URL in both.
21
+ const URL_PATTERNS = [
22
+ /\bsrc\s*=\s*(["'])(https?:\/\/[^"']+?)\1/gi,
23
+ /url\(\s*(["']?)(https?:\/\/[^)"']+?)\1\s*\)/gi,
24
+ ];
25
+ function collectRemoteUrls(html) {
26
+ const urls = new Set();
27
+ for (const pattern of URL_PATTERNS) {
28
+ const re = new RegExp(pattern.source, pattern.flags);
29
+ let match;
30
+ while ((match = re.exec(html)) !== null) {
31
+ urls.add(match[2]);
32
+ }
33
+ }
34
+ return [...urls];
35
+ }
36
+ async function fetchAsDataUri(url, fetchImpl) {
37
+ const ac = new AbortController();
38
+ const timer = setTimeout(() => ac.abort(), PER_IMAGE_TIMEOUT_MS);
39
+ try {
40
+ const res = await fetchImpl(url, { signal: ac.signal, redirect: "follow" });
41
+ if (!res.ok) {
42
+ throw new Error(`HTTP ${res.status}`);
43
+ }
44
+ const type = (res.headers.get("content-type") || "").split(";")[0].trim();
45
+ if (!type.startsWith("image/")) {
46
+ throw new Error(`not an image (${type || "unknown content-type"})`);
47
+ }
48
+ const buf = Buffer.from(await res.arrayBuffer());
49
+ if (buf.byteLength > MAX_IMAGE_BYTES) {
50
+ throw new Error(`too large (${buf.byteLength} bytes)`);
51
+ }
52
+ return `data:${type};base64,${buf.toString("base64")}`;
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+ /**
59
+ * Fetch every remote image referenced in `html` and rewrite the references to
60
+ * `data:` URIs. Fetches run in parallel, each bounded by its own timeout, so
61
+ * total latency is roughly the slowest single image, not the sum.
62
+ *
63
+ * @param fetchImpl - injectable for tests; defaults to global `fetch`.
64
+ */
65
+ export async function inlineRemoteImages(html, fetchImpl = fetch) {
66
+ const urls = collectRemoteUrls(html);
67
+ if (urls.length === 0) {
68
+ return { html, inlined: 0, failed: [] };
69
+ }
70
+ const dataUriByUrl = new Map();
71
+ const failed = [];
72
+ await Promise.all(urls.map(async (url) => {
73
+ try {
74
+ dataUriByUrl.set(url, await fetchAsDataUri(url, fetchImpl));
75
+ }
76
+ catch (err) {
77
+ failed.push({ url, reason: err instanceof Error ? err.message : String(err) });
78
+ }
79
+ }));
80
+ // Swap each URL inside its matched attribute/url() context only, so a URL
81
+ // that is a prefix of another can't be corrupted by a blind global replace.
82
+ let out = html;
83
+ for (const pattern of URL_PATTERNS) {
84
+ const re = new RegExp(pattern.source, pattern.flags);
85
+ out = out.replace(re, (full, _quote, url) => {
86
+ const dataUri = dataUriByUrl.get(url);
87
+ return dataUri ? full.replace(url, dataUri) : full;
88
+ });
89
+ }
90
+ return { html: out, inlined: dataUriByUrl.size, failed };
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ammduncan/easel",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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",