@ammduncan/easel 0.2.12 → 0.2.14

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,19 @@
2
2
 
3
3
  All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 0.2.14 — 2026-05-23
6
+
7
+ ### Changed
8
+ - **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:
9
+ - `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.
10
+ - `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.
11
+ - After upgrading, run `easel update` once so setup re-runs and cleans your existing `~/.claude/settings.json`.
12
+
13
+ ## 0.2.13 — 2026-05-23
14
+
15
+ ### Added
16
+ - **`kind: "mockup"` and `kind: "app"` switch the iframe into app-fidelity mode.** The wrapper skips its presentation defaults (Inter body font, design-token CSS, semantic chips, body bg/color, prose width constraints) and only keeps the box-sizing reset and the html-to-image bridge. Agent paints everything. Makes the existing "App/UI recreations are always locked-mode" rule structural — for a true mockup, opt in via `kind` and the wrapper stops fighting you instead of relying on the agent remembering to override every default. Presentation pushes (explanations, comparisons, status reports) keep the existing wrapper as before.
17
+
5
18
  ## 0.2.12 — 2026-05-23
6
19
 
7
20
  ### Docs
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) before re-adding the current Node-based hook.
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 });
@@ -585,7 +585,7 @@
585
585
  iframe.setAttribute("scrolling", "no");
586
586
  iframe.setAttribute("title", push.title || "push " + push.index);
587
587
  iframe.dataset.pushId = push.id;
588
- iframe.srcdoc = wrapPushedHtml(push.html, currentTheme(), push.id);
588
+ iframe.srcdoc = wrapPushedHtml(push.html, currentTheme(), push.id, push.kind);
589
589
  iframe.addEventListener("load", () => {
590
590
  iframes.add(iframe);
591
591
  // Primary path: the iframe self-measures and posts back size via
@@ -607,7 +607,7 @@
607
607
  - write plain HTML (<h1>, <h2>, <p>, etc.) — gets styled for free, OR
608
608
  - write their own <style> and override anything.
609
609
  ============================================================ */
610
- function wrapPushedHtml(html, theme, pushId) {
610
+ function wrapPushedHtml(html, theme, pushId, kind) {
611
611
  // Authors sometimes wrap payloads in <![CDATA[ ... ]]> (treating html
612
612
  // like CDATA-in-XML). Strip the XML-ism before doing anything else —
613
613
  // otherwise the iframe renders the CDATA tags as visible text.
@@ -620,7 +620,8 @@
620
620
  if (lower.startsWith("<!doctype") || lower.startsWith("<html")) {
621
621
  return injectBridge(cleaned, theme, preset, pushId);
622
622
  }
623
- return buildDefaultWrapper(cleaned, theme, preset, pushId);
623
+ const isAppFidelity = kind === "mockup" || kind === "app";
624
+ return buildDefaultWrapper(cleaned, theme, preset, pushId, isAppFidelity);
624
625
  }
625
626
 
626
627
  function selfMeasureScript(pushId) {
@@ -631,8 +632,29 @@
631
632
  );
632
633
  }
633
634
 
634
- function buildDefaultWrapper(body, theme, preset, pushId) {
635
+ function buildDefaultWrapper(body, theme, preset, pushId, appFidelity) {
635
636
  const density = currentDensity();
637
+ // app-fidelity mode: skip presentation defaults (presets, semantic chips,
638
+ // body font/bg/color, prose constraints). Agent paints everything. Only
639
+ // keeps box-sizing reset + the html-to-image bridge script.
640
+ if (appFidelity) {
641
+ return `<!DOCTYPE html>
642
+ <html data-theme="${theme}" data-preset="${preset}" data-density="${density}" data-app-fidelity="true">
643
+ <head>
644
+ <meta charset="utf-8" />
645
+ <base target="_blank" />
646
+ <script src="https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js"></script>
647
+ <style>
648
+ *, *::before, *::after { box-sizing: border-box; }
649
+ html, body { margin: 0; padding: 0; }
650
+ </style>
651
+ </head>
652
+ <body>
653
+ ${body}
654
+ <script>${selfMeasureScript(pushId)}</script>
655
+ </body>
656
+ </html>`;
657
+ }
636
658
  return `<!DOCTYPE html>
637
659
  <html data-theme="${theme}" data-preset="${preset}" data-density="${density}">
638
660
  <head>
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 + no hook-managed tab: auto-open one tab.
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" };
@@ -82,7 +72,7 @@ const inputSchema = {
82
72
  },
83
73
  kind: {
84
74
  type: "string",
85
- description: "Freeform tag: mockup, diff, explanation, comparison, diagram, status, progress, etc.",
75
+ description: "Freeform tag: mockup, app, diff, explanation, comparison, diagram, status, progress, etc. SPECIAL: 'mockup' and 'app' switch the iframe into APP-FIDELITY mode — the wrapper skips its presentation defaults (Inter body font, design-token CSS, semantic chips, prose width constraints, body bg/color). Only the box-sizing reset and the html-to-image bridge stay. Use this when the push is a recreation of real UI (app screen, component instance, embedded preview) and you want full control over every pixel without the host theme leaking in. For presentation content (explanations, comparisons, status reports), omit kind or use a non-fidelity value.",
86
76
  },
87
77
  },
88
78
  required: ["html"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ammduncan/easel",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
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",