@ammduncan/easel 0.6.1 → 0.7.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,30 @@
2
2
 
3
3
  All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.7.0 — 2026-06-27
8
+
9
+ ### Changed
10
+ - **Pushed cards are now immutable snapshots — each is sealed to one light/dark mode at push time and never reflows when the global theme is toggled.** Previously every rendered card live-tracked the global theme: a config/theme message was broadcast into every iframe, so toggling Easel light↔dark flipped *all* existing cards — and forced authors into adaptive `light-dark()` styling that frequently produced dark-on-dark / washed-out text. Now the iframe-config broadcast and the in-iframe config/theme listeners are removed (print is preserved); a card's `data-theme`/`preset`/`density` are baked once at render. The global toggle still restyles Easel's own chrome and sets the default for *future* pushes — it just never touches existing cards. Verified live: with two cards pushed under different modes, toggling the global light↔dark left both sealed (the light card stayed light, the dark stayed dark). The convention across the `push` tool description + `using-easel` SKILL/kit inverts accordingly: "own your canvas, commit to one mode" replaces "adapt to both modes via `light-dark()`".
11
+ - **The default push wrapper no longer injects the design-system token layer — authors own the canvas.** With cards now frozen to one mode, the six-combo `--ds-*` preset token block, the presentation type scale, and the Inter webfont are dropped from the default wrapper. What remains is a minimal floor: a box-sizing reset, a system-sans default, a committed base surface + ink for the sealed mode, and the prose-width reading cap (plus the `.full-bleed` escape). The structural primitives (`.window`/`.code`/`.terminal`/`.easel-*`) and semantic chips are **retained**, and the `using-easel` kit (`easel-base.css`) re-supplies the full presentation scaffold on demand via its `light-dark()` token fallbacks — so kit-based pushes are unchanged, while bare semantic HTML now renders on the clean floor instead of the auto-injected design system. Verified by rendering bare-HTML, kit-inlined, and primitive (`.window`/`.code`/`.chip`) pushes against the live build.
12
+
13
+ ### Removed
14
+ - **The injected `--ds-*` preset token block, presentation type scale, and Inter webfont** from the default push wrapper (the "own the canvas" change above). Pushes that relied on the auto-injected tokens/type *without* inlining the kit render plainer; inline `easel-base.css` for the presentation scaffold.
15
+
16
+ ### Fixed
17
+ - **Reserved primitive class names (`code`/`terminal`/`window`) no longer render invisible dark-on-dark when an author reuses them.** Two correct-in-isolation rules were mutually destructive: the structural primitives set a fixed dark background + light ink at specificity `(0,1,0)`, while the documented `.wrap * { color: inherit }` guard is *also* `(0,1,0)` but lives later in source order (author `<body>` vs injected `<head>`), so it won the tie and flipped the primitive's ink back to the author's near-black `.wrap` colour → near-black text on the primitive's dark fill, invisible. Because the names are generic English words, author markup naturally reused them (`<td class="code">`, `<span class="code">`) and inherited the dark fill unintentionally. Fixes: (1) **Hardened** — each primitive's `background` + `color` are now committed with `!important` on the *container only* (not on `*`/token rules), so `.wrap * { color: inherit }` can't flip the ink; syntax-highlight tokens still win by specificity and are untouched. Existing content becomes readable with no author change. (2) **Namespaced** — `.easel-code` / `.easel-terminal` / `.easel-window` (and `[data-easel="code|terminal|window"]`) are the canonical collision-free forms; bare `code`/`terminal`/`window` remain as deprecated aliases. (3) **Warned** — the in-iframe guard now logs a console warning when a reserved primitive name lands on an inline/table element (`span`/`td`/…), the accidental-collision signature; the existing low-contrast warning points at the same cause. `using-easel` SKILL + kit guide document the reserved names, the `.easel-*` forms, and the `mono`/`<code>` workaround. Verified by rendering the exact `<td class="code">` / `<span class="code">` / `.code` / `.easel-code` repro against the real injected CSS in light mode.
18
+
19
+ ### Added
20
+ - **`theme: 'light' | 'dark'` param on the `push` tool — declares the card's sealed canvas mode.** Frozen at push time; never flips with the global toggle. Omit to snapshot the current global theme (also frozen). Pairs with the immutable-snapshot change above: an author told "present in dark" passes `theme:'dark'` and gets a card that stays dark regardless of the viewer's global setting.
21
+ - **`EASEL_SUPPRESS_SESSION=1` env var suppresses switcher-session registration.** When set on a `claude` (or any client) invocation, easel still loads as an MCP but every tool (`push`/`label`/`open`/`config`) short-circuits to a no-op — the MCP never contacts the HTTP server and the session never appears in the switcher. The SessionStart hook also skips its convention reminder so the agent isn't nagged to label a session that can't register. Built for automated/headless consumers that run easel-registered Claude on a tight cadence (e.g. the ammiels-bot dispatcher tick fired every ~60s by launchd), which otherwise pile up churny "Bot watcher tick" entries in the switcher. Deliberately MCP-local: it leaves the Slack connector and every other MCP/account connector fully intact, unlike `claude --strict-mcp-config`, which also strips the claude.ai account connectors.
22
+
23
+ ## 0.6.2 — 2026-06-04
24
+
25
+ ### Fixed
26
+ - **`setup` now actually puts `easel` on PATH, making the documented bare CLI commands truthful.** The README's CLI section documents `easel open / update / restart / …`, but neither install path ever exposed the binary: `npx -y @ammduncan/easel setup` runs once from the ephemeral npx cache and leaves nothing behind, and clone installs' `bin/easel setup` never linked. Setup now closes the gap per install flavor: clone installs get `npm link` (skipped when `easel` already resolves); npx-cache runs get a real `npm install -g` pinned to their own version, then **delegate setup to the global copy** so the MCP/hook registrations point at paths that survive npx cache pruning (the cache-pruning drift left one machine's MCP pinned to a stale 0.5.1 cache while 0.6.x shipped). Failures degrade to a printed hint — setup never hard-fails on the PATH step.
27
+ - **`easel update` now works for npm installs.** It previously assumed a git checkout (`git pull` flavor, "clone installs only") and just failed elsewhere. Without a `.git` dir it now runs `npm install -g @ammduncan/easel@latest` and re-runs setup from the new copy.
28
+
5
29
  ## 0.6.1 — 2026-06-04
6
30
 
7
31
  ### Changed
package/README.md CHANGED
@@ -31,7 +31,7 @@ Requires Node 20+.
31
31
  npx -y @ammduncan/easel setup
32
32
  ```
33
33
 
34
- That registers the MCP at user scope, installs the `using-easel` skill so the agent knows when to push, and adds the `SessionStart` hooks that resolve session IDs and auto-open the tab. Restart Claude Code and you're done.
34
+ That registers the MCP at user scope, installs the `using-easel` skill so the agent knows when to push, adds the `SessionStart` hooks that resolve session IDs and auto-open the tab, and installs the package globally so the bare `easel` command works from any shell. Restart Claude Code and you're done.
35
35
 
36
36
  ### Cursor / Claude Desktop / Windsurf / Codex
37
37
 
@@ -81,6 +81,8 @@ npm install && npm run build
81
81
  bin/easel setup
82
82
  ```
83
83
 
84
+ Setup runs `npm link`, so bare `easel` works from any shell afterwards.
85
+
84
86
  ## Tools the agent gets
85
87
 
86
88
  | Tool | What it does |
@@ -134,7 +136,7 @@ easel config print / set { preset, theme, density }
134
136
  easel setup Claude Code: hooks + MCP + skill
135
137
  easel setup --client <name> register the MCP in another client (cursor, claude-desktop, windsurf, codex)
136
138
  easel restart kill + respawn the HTTP server (handy after a build)
137
- easel update git pull + build + setup (clone installs only)
139
+ easel update clone installs: git pull + build + setup · npm installs: npm install -g @latest + setup
138
140
  easel server run the HTTP server in the foreground (debug)
139
141
  easel version
140
142
  ```
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, join, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { homedir } from "node:os";
7
7
  import { ensureHttpServer, readLock } from "./server-manager.js";
@@ -11,6 +11,7 @@ import { registerSession } from "./session-store.js";
11
11
  import { listClients, setupClient, } from "./client-setup.js";
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
  const PROJECT_ROOT = resolve(__dirname, "..");
14
+ const NPM_PKG = "@ammduncan/easel";
14
15
  function help() {
15
16
  console.log(`easel — live browser feed for Claude Code for Claude Code sessions
16
17
 
@@ -28,7 +29,7 @@ Usage:
28
29
  easel setup --client claude-desktop register MCP in ~/Library/Application Support/Claude/claude_desktop_config.json
29
30
  easel setup --client windsurf register MCP in ~/.codeium/windsurf/mcp_config.json
30
31
  easel setup --client codex register MCP in ~/.codex/config.toml + copy skill to ~/.codex/skills/
31
- easel update git pull + npm install + build + setup (re-runs setup to apply new conventions)
32
+ easel update clone installs: git pull + build + setup · npm installs: npm install -g @latest + setup
32
33
  easel mcp run the stdio MCP server in the foreground (used by clients)
33
34
  easel restart kill the running HTTP server and respawn it (picks up new builds/paths)
34
35
  easel server run the HTTP server in the foreground (debug)
@@ -97,7 +98,91 @@ function openInBrowser(url) {
97
98
  console.error(`[easel] couldn't open browser: ${err.message}`);
98
99
  }
99
100
  }
101
+ function binResolvesOnPath() {
102
+ const cmd = process.platform === "win32" ? "where" : "which";
103
+ try {
104
+ const r = spawnSync(cmd, ["easel"], { encoding: "utf-8" });
105
+ return r.status === 0 && r.stdout.trim().length > 0;
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ function pkgVersion() {
112
+ try {
113
+ const pkg = JSON.parse(readFileSync(join(PROJECT_ROOT, "package.json"), "utf-8"));
114
+ return pkg.version ?? "latest";
115
+ }
116
+ catch {
117
+ return "latest";
118
+ }
119
+ }
120
+ function globalPkgDir() {
121
+ try {
122
+ const r = spawnSync("npm", ["root", "-g"], { encoding: "utf-8" });
123
+ if (r.status !== 0)
124
+ return null;
125
+ const dir = join(r.stdout.trim(), NPM_PKG);
126
+ return existsSync(dir) ? dir : null;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ // Re-runs setup from the globally installed copy so its registrations point at
133
+ // the global paths. Returns false if there is no global copy to delegate to.
134
+ function rerunSetupFromGlobal() {
135
+ const dir = globalPkgDir();
136
+ if (!dir) {
137
+ return false;
138
+ }
139
+ const r = spawnSync("node", [join(dir, "dist", "cli.js"), "setup"], {
140
+ stdio: "inherit",
141
+ env: { ...process.env, EASEL_SETUP_CHILD: "1" },
142
+ });
143
+ return r.status === 0;
144
+ }
145
+ // Make bare `easel` work after setup, as the README documents. Clone installs
146
+ // get `npm link`. npx-cache installs get a real global install — the cache is
147
+ // pruned unpredictably — and setup is then re-run from the global copy so the
148
+ // MCP/hook registrations point at paths that survive pruning.
149
+ // Returns true when setup was fully delegated to the global copy.
150
+ function ensureBinOnPath() {
151
+ const inNpxCache = PROJECT_ROOT.split(sep).includes("_npx");
152
+ if (!inNpxCache) {
153
+ if (binResolvesOnPath()) {
154
+ return false;
155
+ }
156
+ const r = spawnSync("npm", ["link", "--silent", "--no-audit", "--no-fund"], {
157
+ cwd: PROJECT_ROOT,
158
+ stdio: "ignore",
159
+ });
160
+ if (r.status === 0) {
161
+ console.log(" - linked `easel` into the global bin (npm link)");
162
+ }
163
+ else {
164
+ console.warn(` - couldn't put \`easel\` on PATH — run \`npm link\` in ${PROJECT_ROOT}`);
165
+ }
166
+ return false;
167
+ }
168
+ const version = pkgVersion();
169
+ console.log(`[easel] installing ${NPM_PKG}@${version} globally so \`easel\` is on PATH…`);
170
+ const installed = spawnSync("npm", ["install", "-g", "--silent", "--no-audit", "--no-fund", `${NPM_PKG}@${version}`], { stdio: "inherit" });
171
+ if (installed.status !== 0 || !rerunSetupFromGlobal()) {
172
+ console.warn(` - global install failed — \`easel\` won't be on PATH; run \`npm install -g ${NPM_PKG}\` manually`);
173
+ return false;
174
+ }
175
+ return true;
176
+ }
100
177
  function cmdSetup() {
178
+ // Put `easel` on PATH first — for npx-cache runs this delegates the whole
179
+ // setup to a freshly installed global copy (so registered paths outlive the
180
+ // cache), in which case there's nothing left to do here.
181
+ if (!process.env.EASEL_SETUP_CHILD) {
182
+ if (ensureBinOnPath()) {
183
+ return;
184
+ }
185
+ }
101
186
  mkdirSync(HOOK_DIR, { recursive: true });
102
187
  const settingsPath = join(homedir(), ".claude", "settings.json");
103
188
  const hookScript = resolve(PROJECT_ROOT, "scripts", "easel-session-id.mjs");
@@ -264,6 +349,25 @@ async function cmdConfig(args) {
264
349
  function cmdUpdate() {
265
350
  console.log("[easel] checking for updates…");
266
351
  const run = (cmd, args) => spawnSync(cmd, args, { stdio: "inherit", cwd: PROJECT_ROOT });
352
+ // npm/global installs have no git checkout — update from the registry and
353
+ // re-run setup from the new copy so registrations track the new paths.
354
+ if (!existsSync(join(PROJECT_ROOT, ".git"))) {
355
+ const installed = run("npm", [
356
+ "install",
357
+ "-g",
358
+ "--no-audit",
359
+ "--no-fund",
360
+ `${NPM_PKG}@latest`,
361
+ ]);
362
+ if (installed.status !== 0) {
363
+ console.error("[easel] npm install -g failed");
364
+ process.exitCode = 1;
365
+ return;
366
+ }
367
+ rerunSetupFromGlobal();
368
+ console.log("[easel] updated. Restart Claude Code to pick up tool/skill changes.");
369
+ return;
370
+ }
267
371
  let r = run("git", ["fetch", "--quiet", "origin", "main"]);
268
372
  if (r.status !== 0) {
269
373
  console.error("[easel] git fetch failed");