@ammduncan/easel 0.2.13 → 0.2.15
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 +15 -0
- package/dist/cli.js +6 -8
- package/dist/client/viewer.js +21 -7
- package/dist/mcp.js +7 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 0.2.15 — 2026-05-23
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **PDF exports were enormous (300+ MB for a single-page card).** Two stacking causes: (1) the iframe always rasterised at `pixelRatio: 4` regardless of target, producing huge bitmaps for tall cards; (2) the parent then embedded the result into jsPDF as a `PNG`, which PDFs store using Flate compression — far less efficient than the DCT compression PDFs natively use for JPEGs. A tall card at DPR 4 → ~6000×10000 pixel PNG → ~300 MB PDF wrapper.
|
|
9
|
+
- Fix: for PDF targets only, the iframe now rasterises as JPEG at `quality: 0.92` and `pixelRatio: 2`, and the parent embeds with `'JPEG'` format + `'FAST'` compression flag + `compress: true` at the document level. PNG exports stay at lossless PNG + pixelRatio 4 — no quality loss for the standalone PNG download.
|
|
10
|
+
- Expected sizes for a typical card: ~3–8 MB (down from ~300 MB), text still crisp on screen and in print.
|
|
11
|
+
|
|
12
|
+
## 0.2.14 — 2026-05-23
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **First-push auto-open is now actually the only trigger.** 0.2.10 moved the MCP-side auto-open from "first tool call" to "first push when no tab is alive". But Claude Code launches were still opening a tab because `easel setup` also installed a `SessionStart` hook that ran `easel open --quiet`. Two changes:
|
|
16
|
+
- `easel setup` no longer installs the `easel open --quiet` SessionStart block, and actively strips any pre-existing one from `~/.claude/settings.json`. The `idCaptureBlock` (which writes the per-PPID session-id file) stays — it's needed for PPID → session correlation and has no UI side effect.
|
|
17
|
+
- `mcp.ts` `autoOpenIfNeeded` drops the `hookHasFiredForThisPpid()` short-circuit. That check was a proxy for "the hook already opened a tab"; now that the hook doesn't open tabs, `sessionTabs > 0` is the truthful signal.
|
|
18
|
+
- After upgrading, run `easel update` once so setup re-runs and cleans your existing `~/.claude/settings.json`.
|
|
19
|
+
|
|
5
20
|
## 0.2.13 — 2026-05-23
|
|
6
21
|
|
|
7
22
|
### Added
|
package/dist/cli.js
CHANGED
|
@@ -124,7 +124,8 @@ function cmdSetup() {
|
|
|
124
124
|
const hooks = settings.hooks ?? {};
|
|
125
125
|
let sessionStart = hooks.SessionStart ?? [];
|
|
126
126
|
// Drop legacy entries from prior versions (the old bash hook, paths under the
|
|
127
|
-
// claude-display name)
|
|
127
|
+
// claude-display name) AND the prior autoOpenBlock (which opened a tab at
|
|
128
|
+
// SessionStart — replaced by MCP-side auto-open on first push as of 0.2.14).
|
|
128
129
|
const isLegacy = (block) => {
|
|
129
130
|
const inner = block?.hooks ?? [block];
|
|
130
131
|
if (!Array.isArray(inner))
|
|
@@ -135,16 +136,16 @@ function cmdSetup() {
|
|
|
135
136
|
return false;
|
|
136
137
|
return (cmd.includes("claude-display-session-id.sh") ||
|
|
137
138
|
cmd.includes("easel-session-id.sh") ||
|
|
138
|
-
cmd.includes("bin/claude-display ")
|
|
139
|
+
cmd.includes("bin/claude-display ") ||
|
|
140
|
+
// Prior `easel open --quiet` SessionStart hook — superseded by
|
|
141
|
+
// MCP-side first-push auto-open.
|
|
142
|
+
(cmd.includes("easel") && cmd.includes("open --quiet")));
|
|
139
143
|
});
|
|
140
144
|
};
|
|
141
145
|
sessionStart = sessionStart.filter((b) => !isLegacy(b));
|
|
142
146
|
const idCaptureBlock = {
|
|
143
147
|
hooks: [{ type: "command", command: `node ${hookScript}` }],
|
|
144
148
|
};
|
|
145
|
-
const autoOpenBlock = {
|
|
146
|
-
hooks: [{ type: "command", command: `${cliEntry} open --quiet` }],
|
|
147
|
-
};
|
|
148
149
|
const containsBlockMatching = (substr) => sessionStart.some((block) => {
|
|
149
150
|
const inner = block?.hooks ?? [block];
|
|
150
151
|
return (Array.isArray(inner) ? inner : []).some((h) => typeof h === "object" &&
|
|
@@ -155,9 +156,6 @@ function cmdSetup() {
|
|
|
155
156
|
if (!containsBlockMatching("easel-session-id.mjs")) {
|
|
156
157
|
sessionStart.push(idCaptureBlock);
|
|
157
158
|
}
|
|
158
|
-
if (!containsBlockMatching("easel") || !containsBlockMatching("open --quiet")) {
|
|
159
|
-
sessionStart.push(autoOpenBlock);
|
|
160
|
-
}
|
|
161
159
|
hooks.SessionStart = sessionStart;
|
|
162
160
|
settings.hooks = hooks;
|
|
163
161
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
package/dist/client/viewer.js
CHANGED
|
@@ -196,8 +196,12 @@
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
|
-
* Embed a
|
|
200
|
-
* dimensions, producing a continuous (no page-breaks) document, then
|
|
199
|
+
* Embed a rasterised dataURL into a single-page PDF sized to the image's
|
|
200
|
+
* pixel dimensions, producing a continuous (no page-breaks) document, then
|
|
201
|
+
* save. The iframe sends a JPEG dataURL for PDF targets — PDFs natively use
|
|
202
|
+
* DCT compression for JPEGs, so embedding stays compact (vs PNGs which
|
|
203
|
+
* balloon the file). We detect format from the dataURL prefix; PNG still
|
|
204
|
+
* works as a fallback for any legacy caller that sends one.
|
|
201
205
|
*/
|
|
202
206
|
function downloadAsPdf(dataUrl, filename) {
|
|
203
207
|
return new Promise((resolve, reject) => {
|
|
@@ -206,6 +210,8 @@
|
|
|
206
210
|
reject(new Error("jsPDF not loaded"));
|
|
207
211
|
return;
|
|
208
212
|
}
|
|
213
|
+
const isJpeg = dataUrl.startsWith("data:image/jpeg") || dataUrl.startsWith("data:image/jpg");
|
|
214
|
+
const imageType = isJpeg ? "JPEG" : "PNG";
|
|
209
215
|
const img = new Image();
|
|
210
216
|
img.onload = () => {
|
|
211
217
|
try {
|
|
@@ -216,8 +222,9 @@
|
|
|
216
222
|
format: [w, h],
|
|
217
223
|
orientation: w > h ? "landscape" : "portrait",
|
|
218
224
|
hotfixes: ["px_scaling"],
|
|
225
|
+
compress: true,
|
|
219
226
|
});
|
|
220
|
-
pdf.addImage(dataUrl,
|
|
227
|
+
pdf.addImage(dataUrl, imageType, 0, 0, w, h, undefined, "FAST");
|
|
221
228
|
pdf.save(filename);
|
|
222
229
|
resolve();
|
|
223
230
|
} catch (err) {
|
|
@@ -859,13 +866,20 @@ ${body}
|
|
|
859
866
|
document.documentElement.scrollHeight,
|
|
860
867
|
document.body ? document.body.scrollHeight : 0,
|
|
861
868
|
);
|
|
862
|
-
|
|
869
|
+
// PNG target → lossless PNG @ pixelRatio 4 for crisp standalone files.
|
|
870
|
+
// PDF target → JPEG @ quality 0.92 + pixelRatio 2. PDFs natively use
|
|
871
|
+
// DCT compression for embedded images, so JPEG-in-PDF stays small;
|
|
872
|
+
// PNG-in-PDF balloons (a tall card at DPR 4 produces 300+ MB PDFs).
|
|
873
|
+
var rasterFn = format === "pdf" ? window.htmlToImage.toJpeg : window.htmlToImage.toPng;
|
|
874
|
+
var rasterOpts = {
|
|
863
875
|
backgroundColor: bgColor,
|
|
864
|
-
pixelRatio: 4,
|
|
876
|
+
pixelRatio: format === "pdf" ? 2 : 4,
|
|
865
877
|
cacheBust: true,
|
|
866
878
|
width: width,
|
|
867
879
|
height: height,
|
|
868
|
-
}
|
|
880
|
+
};
|
|
881
|
+
if (format === "pdf") rasterOpts.quality = 0.92;
|
|
882
|
+
rasterFn(document.documentElement, rasterOpts).then(function(dataUrl){
|
|
869
883
|
parent.postMessage({ type: "easel:image-ready", pushId: pushId, dataUrl: dataUrl, filename: filename, format: format }, "*");
|
|
870
884
|
}).catch(function(err){
|
|
871
885
|
console.error("[easel] export failed", err);
|
|
@@ -889,7 +903,7 @@ ${body}
|
|
|
889
903
|
const configScript =
|
|
890
904
|
"<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(" +
|
|
891
905
|
JSON.stringify({ theme, preset, density }) +
|
|
892
|
-
");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
|
|
906
|
+
");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:fmt==='pdf'?2:4,cacheBust:true};if(fmt==='pdf')ropts.quality=0.92;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>";
|
|
893
907
|
const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
|
|
894
908
|
const combined = configScript + measureScript;
|
|
895
909
|
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
|
package/dist/mcp.js
CHANGED
|
@@ -3,11 +3,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
8
6
|
import { ensureHttpServer } from "./server-manager.js";
|
|
9
7
|
import { resolveClaudeSessionId } from "./session-id.js";
|
|
10
|
-
import { HOOK_DIR } from "./paths.js";
|
|
11
8
|
function openUrlInBrowser(url) {
|
|
12
9
|
const platform = process.platform;
|
|
13
10
|
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
@@ -24,14 +21,6 @@ const TOOL_PUSH = "push";
|
|
|
24
21
|
const TOOL_OPEN = "open";
|
|
25
22
|
const TOOL_CONFIG = "config";
|
|
26
23
|
const TOOL_LABEL = "label";
|
|
27
|
-
/**
|
|
28
|
-
* True if a SessionStart hook (e.g. Claude Code's) has already written a
|
|
29
|
-
* session-id file for this MCP child's PPID. Tells us a hook-aware client is
|
|
30
|
-
* managing tab lifecycle, so the MCP server should NOT also auto-open.
|
|
31
|
-
*/
|
|
32
|
-
function hookHasFiredForThisPpid() {
|
|
33
|
-
return existsSync(join(HOOK_DIR, `cc-session-${process.ppid}.txt`));
|
|
34
|
-
}
|
|
35
24
|
// One-shot guard: only auto-open once per MCP-child lifetime. If the user
|
|
36
25
|
// closes the tab afterwards, subsequent pushes won't re-open it — the user
|
|
37
26
|
// closing the tab is treated as an explicit dismissal we should respect.
|
|
@@ -43,10 +32,16 @@ let autoOpenAttempted = false;
|
|
|
43
32
|
* - `otherTabs > 0`: easel is open, but on a different session. Don't surprise
|
|
44
33
|
* the user with another window — return "other-session" so the caller can
|
|
45
34
|
* tell the agent to ask whether to use the topbar switcher or open a new tab.
|
|
46
|
-
* - both 0
|
|
35
|
+
* - both 0: auto-open one tab.
|
|
47
36
|
*
|
|
48
37
|
* One-shot per MCP child lifetime. Closing the tab counts as dismissal;
|
|
49
38
|
* subsequent pushes won't re-open.
|
|
39
|
+
*
|
|
40
|
+
* Note: until 0.2.13, this also short-circuited when the Claude Code
|
|
41
|
+
* SessionStart hook had fired (`hookHasFiredForThisPpid`), because the hook
|
|
42
|
+
* itself opened a tab. As of 0.2.14 `easel setup` no longer installs that
|
|
43
|
+
* hook, so the MCP-side decision is reactive to actual tab presence only —
|
|
44
|
+
* `sessionTabs` is the truthful signal.
|
|
50
45
|
*/
|
|
51
46
|
function autoOpenIfNeeded(url, sessionTabs, otherTabs) {
|
|
52
47
|
if (autoOpenAttempted)
|
|
@@ -60,11 +55,6 @@ function autoOpenIfNeeded(url, sessionTabs, otherTabs) {
|
|
|
60
55
|
autoOpenAttempted = true;
|
|
61
56
|
return { kind: "other-session" };
|
|
62
57
|
}
|
|
63
|
-
if (hookHasFiredForThisPpid()) {
|
|
64
|
-
// Claude Code's SessionStart hook handled it (or tried to).
|
|
65
|
-
autoOpenAttempted = true;
|
|
66
|
-
return { kind: "noop" };
|
|
67
|
-
}
|
|
68
58
|
autoOpenAttempted = true;
|
|
69
59
|
openUrlInBrowser(url);
|
|
70
60
|
return { kind: "opened" };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ammduncan/easel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
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",
|