@ammduncan/easel 0.5.1 → 0.6.1

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.6.1 — 2026-06-04
6
+
7
+ ### Changed
8
+ - **The "different session" push hint now requires the surface-the-tab question to be asked ALONE.** When a push lands while easel is open on another session, the tool result tells the agent to ask the user whether to (a) switch the open tab or (b) open a fresh one. Agents were bundling that question into a multi-question prompt alongside follow-ups that assumed the user had already *seen* the pushed content — which they hadn't, since the tab wasn't surfaced yet. The hint now spells it out: ask this one question first, by itself; once the tab is visible, ask the rest. Prompt-text only; no runtime change.
9
+
10
+ ## 0.6.0 — 2026-06-03
11
+
12
+ ### Fixed
13
+ - **A `100vh` (or `vh`/`dvh`/`svh`) root no longer silently collapses to a stub.** `vh` resolves against the push iframe — which has no intrinsic viewport — so the idiomatic full-screen app shell (`height: 100vh` on the root) measured against the iframe's ~150px default and cropped mid-content; two different local render-window sizes produced pixel-identical collapsed cards, proving the author's intended viewport never reached easel. The self-measure bridge now reports, alongside the existing floored `height`, a **non-floored `content` height** (walks body-child bottoms instead of the viewport-floored `documentElement.scrollHeight`) and the iframe's own **`vp`**. A parent-side phase machine (`applyMeasuredSize`) leaves normal cards untouched — they size to their content exactly as before, keeping the 150px historical floor — but when content **exactly fills** the viewport (the viewport-lock signature) it probes at a distinct viewport (720px) and, if content tracks it, **pins the card to the 900px desktop canvas** (matching `.window.desktop`) instead of letting it collapse. Stale mid-probe measurements are ignored, and a content that *coincidentally* equals the initial viewport is correctly released to its real height rather than mistaken for `100vh`. Covered by `tests/unit/height-autoguard.test.mjs` (shape) and `tests/unit/height-autoguard-behaviour.test.mjs` (drives the real shipped function through full measurement feedback loops).
14
+
15
+ ### Changed
16
+ - **`push` tool description + `using-easel` skill: documented card sizing and mockup-granularity.** Two additions. (1) A **card-height rule**: card height = your content's intrinsic height; there's no `height` knob and you don't need one (the px you write *is* the height), but avoid `100vh` on the root — it's the one unit that doesn't mean what you think inside the iframe; for a full screen use `min-height: 900px` (or the source's real height) explicitly. (2) A **split-by-role rule** for mockups: the mere presence of a mockup isn't a reason to split it onto its own card — keep it inline (`.full-bleed`, optionally `.window`) when it illustrates the prose's point, but give it its own push (one per screen, `kind:"app"`) when the screen is the primary artifact, when comparing 2+ full screens, or when the user will export/share it standalone. Own-push buys real per-card app fidelity (`kind` is per-push), a faithful frame height, and a clean standalone export.
17
+
5
18
  ## 0.5.1 — 2026-05-31
6
19
 
7
20
  ### Changed
@@ -274,8 +274,88 @@
274
274
  Self-measure message bridge — iframes post their measured body
275
275
  height; we apply it. Reliable across font loads, image loads,
276
276
  and dynamic content (the iframe knows when its DOM mutates).
277
+
278
+ 100vh auto-guard: a root sized with viewport units (100vh/dvh/svh)
279
+ resolves `vh` against THIS iframe, which has no intrinsic viewport,
280
+ so it would otherwise collapse to the iframe's default ~150px. The
281
+ bridge reports a non-floored `content` height plus the iframe's own
282
+ `vp`; when content exactly fills vp (the viewport-lock signature) we
283
+ probe at a distinct viewport and, if content tracks it, pin the card
284
+ to the desktop canvas instead of letting it collapse. Normal cards
285
+ never enter the probe — they size to the measured `height` exactly
286
+ as before, so this is zero-change for non-viewport-relative content.
277
287
  ============================================================ */
278
288
 
289
+ const EASEL_DESKTOP_CANVAS = 900; // matches .window.desktop min-height
290
+ const EASEL_PROBE_PX = 720; // distinct probe viewport for vh-lock detection
291
+ const EASEL_MIN_CARD_PX = 150; // historical floor (the iframe's default height)
292
+ const iframeSizeState = new Map(); // pushId → { phase }
293
+
294
+ function setIframeHeight(iframe, px) {
295
+ iframe.style.height = Math.max(0, Math.ceil(px)) + "px";
296
+ }
297
+
298
+ // Card height = the iframe's NON-floored content height (never the viewport-
299
+ // floored documentElement.scrollHeight, which would re-inflate short content
300
+ // to whatever viewport we last set — fatal once the probe bumps it to 720).
301
+ function trackedHeight(content) {
302
+ return Math.max(EASEL_MIN_CARD_PX, content);
303
+ }
304
+
305
+ function applyMeasuredSize(iframe, data) {
306
+ const content = typeof data.content === "number" ? data.content : data.height;
307
+ const vp = typeof data.vp === "number" ? data.vp : null;
308
+
309
+ // Legacy bridge / no viewport signal: size to the measured height, as before.
310
+ if (vp === null) {
311
+ setIframeHeight(iframe, data.height);
312
+ return;
313
+ }
314
+
315
+ let st = iframeSizeState.get(data.pushId);
316
+ if (!st) {
317
+ st = { phase: "initial" };
318
+ iframeSizeState.set(data.pushId, st);
319
+ }
320
+
321
+ if (st.phase === "pinned") {
322
+ // Viewport-locked root pinned to the desktop canvas; still grow if real
323
+ // overflow content exceeds it.
324
+ setIframeHeight(iframe, Math.max(EASEL_DESKTOP_CANVAS, content));
325
+ return;
326
+ }
327
+ if (st.phase === "tracking") {
328
+ setIframeHeight(iframe, trackedHeight(content));
329
+ return;
330
+ }
331
+ if (st.phase === "probing") {
332
+ // Trust only measurements taken AT the probe viewport — ignore stale
333
+ // pre-resize messages still carrying the old (initial) viewport.
334
+ if (Math.abs(vp - EASEL_PROBE_PX) > 4) return;
335
+ if (content >= EASEL_PROBE_PX - 2) {
336
+ // Content grew to fill the probe viewport → viewport-relative root.
337
+ st.phase = "pinned";
338
+ setIframeHeight(iframe, Math.max(EASEL_DESKTOP_CANVAS, content));
339
+ } else {
340
+ // Content stayed at its intrinsic height → not viewport-locked.
341
+ st.phase = "tracking";
342
+ setIframeHeight(iframe, trackedHeight(content));
343
+ }
344
+ return;
345
+ }
346
+ // st.phase === "initial"
347
+ if (vp > 0 && Math.abs(content - vp) <= 2) {
348
+ // Content exactly fills the iframe's natural viewport — ambiguous: a
349
+ // collapsed viewport-relative (100vh) root, OR content that just happens
350
+ // to equal this height. Probe at a distinct viewport to disambiguate.
351
+ st.phase = "probing";
352
+ setIframeHeight(iframe, EASEL_PROBE_PX);
353
+ } else {
354
+ st.phase = "tracking";
355
+ setIframeHeight(iframe, trackedHeight(content));
356
+ }
357
+ }
358
+
279
359
  window.addEventListener("message", (e) => {
280
360
  const data = e && e.data;
281
361
  if (!data) return;
@@ -285,7 +365,7 @@
285
365
  'iframe[data-push-id="' + cssEscape(data.pushId) + '"]',
286
366
  );
287
367
  if (!iframe) return;
288
- iframe.style.height = Math.max(0, Math.ceil(data.height)) + "px";
368
+ applyMeasuredSize(iframe, data);
289
369
  return;
290
370
  }
291
371
  if (data.type === "easel:click") {
@@ -796,7 +876,7 @@
796
876
  return (
797
877
  "(function(){var ID=" +
798
878
  JSON.stringify(pushId) +
799
- ";function measure(){var b=document.body,h=document.documentElement;if(!b)return 0;return Math.max(b.getBoundingClientRect().bottom,b.scrollHeight,h.scrollHeight)}function send(){try{parent.postMessage({type:'easel:size',pushId:ID,height:measure()},'*')}catch(e){}}send();window.addEventListener('load',send);window.addEventListener('resize',send);if(document.fonts&&document.fonts.ready){document.fonts.ready.then(send).catch(function(){})}if(window.ResizeObserver){var ro=new ResizeObserver(send);if(document.body)ro.observe(document.body);ro.observe(document.documentElement)}var mo=new MutationObserver(send);mo.observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true});setTimeout(send,250);setTimeout(send,800);setTimeout(send,1600)})();"
879
+ ";function measure(){var b=document.body,h=document.documentElement;if(!b)return 0;return Math.max(b.getBoundingClientRect().bottom,b.scrollHeight,h.scrollHeight)}function content(){var b=document.body;if(!b)return 0;var m=Math.max(b.getBoundingClientRect().bottom,b.scrollHeight);var k=b.children;for(var i=0;i<k.length;i++){var bo=k[i].getBoundingClientRect().bottom;if(bo>m){m=bo}}return m}function vp(){return document.documentElement.clientHeight||0}function send(){try{parent.postMessage({type:'easel:size',pushId:ID,height:measure(),content:content(),vp:vp()},'*')}catch(e){}}send();window.addEventListener('load',send);window.addEventListener('resize',send);if(document.fonts&&document.fonts.ready){document.fonts.ready.then(send).catch(function(){})}if(window.ResizeObserver){var ro=new ResizeObserver(send);if(document.body)ro.observe(document.body);ro.observe(document.documentElement)}var mo=new MutationObserver(send);mo.observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true});setTimeout(send,250);setTimeout(send,800);setTimeout(send,1600)})();"
800
880
  );
801
881
  }
802
882
 
@@ -1290,6 +1370,7 @@ ${body}
1290
1370
  obs.disconnect();
1291
1371
  cardObservers.delete(pushId);
1292
1372
  }
1373
+ iframeSizeState.delete(pushId);
1293
1374
  unreadIds.delete(pushId);
1294
1375
  totalPushes = Math.max(0, totalPushes - 1);
1295
1376
  setPushCount(totalPushes);
package/dist/mcp.js CHANGED
@@ -155,10 +155,12 @@ export async function main() {
155
155
  "• Stack desktop mockups VERTICALLY with labels ('Now', 'Proposed') — don't squeeze them side-by-side. The iframe is ~900px wide; two desktop screens at half-width crush columns, wrap headings to 3 lines, and turn tables unreadable.\n" +
156
156
  "• Side-by-side is fine only for narrow mobile mockups, small cards, or short text columns that genuinely fit in half-width.\n" +
157
157
  "• Mockup embedded mid-explanation? Prose is left-aligned and capped ~880px; wrap JUST the mockup in <div class=\"full-bleed\">…</div> and it fills the content column from the SAME left edge (wider than the prose, sharing one left margin; the body padding stays as a gutter so nothing touches the card edge). (If the WHOLE push is a UI recreation, use kind:'mockup'/'app' instead.)\n" +
158
+ "• SPLIT BY ROLE, not by the mere presence of a mockup. A mockup that ILLUSTRATES the prose's point — a fragment (one row, a button, a chip) or a screen the surrounding text is actively walking through — stays INLINE in the prose push (full-bleed, optionally .window); splitting it would fracture one continuous thought into a pile of cards. But give the mockup its OWN push (one card per screen, kind:'app') when: the SCREEN is the primary artifact and the prose is just a caption / lead-in; OR you're comparing 2+ full screens (each its own card → they stack cleanly and export independently); OR the user will export / share the screen standalone (no prose bleeding above and below). Own-push buys what an inline mockup can't: real per-card app fidelity (kind is per-push, so an inline mockup can't get kind:'app'), a faithful frame height, and a clean standalone export. Rule of thumb: same judgment as full-bleed vs kind:'mockup', one level up.\n" +
158
159
  "• Window chrome: wrap a mockup in <div class=\"window\" data-title=\"App name\">…</div> for a macOS window frame (title bar + red/yellow/green traffic-light dots + centred title). Add the `desktop` class (class=\"window desktop\") for the 1440x900 (16:10) desktop canvas via min-height:900px; omit it so dialogs/components size to content. Combine with .full-bleed to fill the column. .window is a STABLE LIGHT canvas — it pins white bg + dark ink + re-scopes color:inherit to children (like .code/.terminal), so it does NOT flip with the host theme and subtle gray-on-white labels stay legible even in a dark-mode viewer; a mockup is a screenshot, it should look the same to everyone. For a genuinely dark-UI mockup add the `dark` class (class=\"window dark\") — don't hand-roll a dark .window. NOTE: .window sets overflow:hidden (to clip its rounded corners) — so NEVER put a fixed `height` on .window or any inner stage, or content past that height is silently guillotined. It's built to grow via min-height.\n" +
159
160
  "• BUILD MOCKUPS FLUID, not fixed-width. Lay the inside out with flex / % / fr widths, not hardcoded width:1440px columns. 1440 is a MAX, not a target. A fluid mockup reflows to fit when the viewer's window is squeezed — no horizontal scroll, nothing clipped, exports stay complete. A fixed-pixel-width mockup gets cut off or needs an awkward horizontal scrollbar when narrowed.\n" +
160
161
  "• NEVER CLIP CONTENT — no fixed `height` + `overflow:hidden` on any container that holds content (cards, panels, device/browser/phone frames, stages, slideovers, toasts). That combo guillotines anything taller than your guessed height — buttons sliced through text, lists cut mid-item. Containers size to their CONTENT: use `min-height` for a floor, NEVER a fixed `height`. `overflow:hidden` is allowed ONLY for genuine cosmetic crops where clipping IS the intent (rounded-corner image masks, decorative bleed) — never on a content region. Decorative frames must grow with their content. When unsure, leave height unset. Mentally render the tallest card: if any text/button could exceed the box, the box is wrong.\n" +
161
162
  "• MATCH THE SOURCE'S REAL FRAME — faithful height, not minimal, in both directions. Mocking a COMPONENT (card, modal, row, toolbar)? Size to content — do NOT pad with min-height:560px to feel 'desktop-y'; that floats content in dead whitespace. Mocking a FULL DESKTOP SCREEN (login page, dashboard)? Give it realistic viewport proportions via MIN-HEIGHT (e.g. min-height:760px or a 16:10 floor — never a fixed `height`, which clips) and lay content out inside as the real screen does (centred form, top nav). Either way copy the source's exact dimensions if it has them, as a min-height. Test: cropped the same way, would your mock look like a screenshot of the real thing? Empty bands = over-padded; a screen squashed to a strip = under-sized.\n" +
163
+ "• CARD HEIGHT = YOUR CONTENT'S INTRINSIC HEIGHT. Easel sizes each card to the rendered height of your HTML — give the root a real height via flowing content or a `min-height`/`height` in **px** and the card is exactly that tall. AVOID `100vh` (or `vh`/`dvh`/`svh`) on the root: those units resolve against easel's own iframe, not a real screen, so a viewport-relative root has no meaningful height and would otherwise COLLAPSE to a stub. Easel auto-detects a viewport-filling root and falls back to the 900px desktop canvas, but don't lean on that guess — for a full desktop screen set `min-height: 900px` (or the source's real height) explicitly; for a component, size to content. px in, predictable card out.\n" +
162
164
  "• When recreating real app UI, hug the source — pull exact colors, spacing, sizing, radii, fonts from the component/theme/Figma/DevTools. A close-but-wrong recreation misleads more than no recreation. If you can't reach the actuals, say so in chat and don't pass the mock off as accurate.\n" +
163
165
  "• One accent color, 3–4 instances max per card. Status colors (red/amber/green) only when state genuinely maps to status.\n\n" +
164
166
  "═══ WHEN TO PUSH ═══\n" +
@@ -312,7 +314,7 @@ export async function main() {
312
314
  }
313
315
  else if (openResult.kind === "other-session") {
314
316
  tabHint =
315
- " · easel is open in another tab, but on a DIFFERENT session — so this push isn't visible yet. ASK THE USER how to surface it, and if your client has an interactive question/prompt tool (e.g. AskUserQuestion), USE IT to offer the choice as clickable options rather than burying it in prose: (a) switch the open tab to this session via the topbar 'switch ▾' dropdown, or (b) have you call the `open` tool to launch a fresh tab. Do not auto-open or pick for them.";
317
+ " · easel is open in another tab, but on a DIFFERENT session — so this push isn't visible yet. ASK THE USER how to surface it, and if your client has an interactive question/prompt tool (e.g. AskUserQuestion), USE IT to offer the choice as clickable options rather than burying it in prose: (a) switch the open tab to this session via the topbar 'switch ▾' dropdown, or (b) have you call the `open` tool to launch a fresh tab. Do not auto-open or pick for them. This question MUST be asked ALONE — never bundle it with other questions in the same prompt call. Any other question you'd ask alongside it almost certainly assumes the user has already seen the pushed content, which they haven't until they surface the tab. Ask this one first, by itself; once the tab is visible, ask the rest.";
316
318
  }
317
319
  else if (result.sessionTabs === 0) {
318
320
  tabHint =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ammduncan/easel",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
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",
@@ -319,6 +319,18 @@ Two cases, two tools. **The deciding question is NOT "does this push contain a m
319
319
  - **Whole push is a single mockup / app recreation** (a dashboard, a screen — nothing but the UI, edge to edge) → `kind: "mockup"` (or `"app"`) on the push. Strips the *presentation* frame (preset tokens, semantic chips, prose-width caps, body bg/color, the Inter webfont) so the content owns the canvas — but **keeps the structural primitives** (`.window`/`.window.dark`, `.code`/`.terminal`) and a neutral system-sans default font, so `.window` and friends still render in a mockup. To match the real app's typeface, **inject its webfont right in the pushed HTML** — a `<link rel="stylesheet" href="…">` or an `@font-face` block loads fine in the sandbox — then set `font-family` on the content; that wins over the sans default. Because app-fidelity strips the body padding too, a full-bleed app screen must **supply its own page padding** (e.g. a `.page { padding: 48px 40px }` wrapper) or it runs to the card edge.
320
320
  - **Prose + embedded mockup(s)** (the common case — intro text, then specimen(s), maybe more text) → leave `kind` **off** so the presentation frame stays on, and wrap each specimen in `<div class="full-bleed">`. You get the best of both: prose lands in the ~56ch reading measure with comfortable side padding, while the specimens fill the full content column (up to 1400px) — **wider** than the prose, never narrower. Tagging this `kind:"mockup"` is the bug: app-fidelity strips the prose-width cap **and** the side padding, so your paragraphs run to the card edge unless you hand-pad — which you will forget.
321
321
 
322
+ #### When a mockup should own its push — split by role, not by presence
323
+
324
+ Having a mockup in the response is *not* itself a reason to split it onto its own card. The trigger is the mockup's **role**:
325
+
326
+ - **Keep it inline** (prose push, `.full-bleed`, optionally `.window`) when the mockup **illustrates the point the prose is making** — a fragment (one row, a button, a chip) or a screen the surrounding text is actively walking through. The reader needs prose → visual → prose continuity; splitting fractures one thought into a pile of cards.
327
+ - **Give it its own push** (one card per screen, `kind:"app"`) when:
328
+ - the **screen is the primary artifact** and the prose is just a caption / lead-in;
329
+ - you're **comparing 2+ full screens** — each its own card, so they stack cleanly and export independently;
330
+ - the user will **export or share the screen standalone** — a clean PNG with no prose bleeding above and below.
331
+
332
+ A mockup gets things on its own push it *can't* get inline: real per-card **app fidelity** (`kind` is per-push, so an inline mockup can never be `kind:"app"`), a **faithful frame height**, and a **clean standalone export**. It's the same full-bleed-vs-`kind:"mockup"` judgment you already make — one level up, at the push boundary.
333
+
322
334
  > **Failure mode (seen in the wild, more than once):** a marketing-kit / lookbook review card — eyebrow, H1, two prose lines, then labelled atom specimens — tagged `kind:"mockup"`. App-fidelity stripped the padding; the author's wrapper had only `padding: 8px 4px`; the prose kissed the card edges. **Fix: drop the `kind`, wrap specimens in `.full-bleed`.** And remember: prose only gets the ~56ch cap when it's a **direct `body` child or inside `div.wrap`** — nest it in a bare `<div>` and the cap silently misses.
323
335
 
324
336
  ### Window chrome for UI mockups
@@ -351,6 +363,17 @@ The width rule above has a vertical twin, and it's the more common footgun: **ne
351
363
  - **Decorative frames** (browser chrome, phone bezel, device frame) must grow with their content — give the frame `min-height` and let it expand, or don't constrain height at all.
352
364
  - **The mental test:** render the tallest card in your head. If any text or button could exceed the container, the container is wrong. When unsure, leave height unset. A mockup exists to show the design *fully* — uniform-looking rectangles are never worth clipped content; let frames be different heights.
353
365
 
366
+ ### Card height = your content's intrinsic height — never `100vh` on the root
367
+
368
+ Easel sizes each card to the **rendered height of your HTML**. Give your root real content, or a `min-height` / `height` in **px**, and the card is exactly that tall. There's no `height` knob on `push` and you don't need one — the px you write *is* the height.
369
+
370
+ The one trap: **`100vh` (or `vh` / `dvh` / `svh`) on the root.** It's the idiomatic way to write a full-screen app shell, but inside easel `vh` resolves against the **push iframe**, which has no real viewport of its own — so a `100vh` root has no meaningful height and would otherwise **collapse to a stub**. Easel auto-detects a viewport-filling root and falls back to the **900px desktop canvas** (the same height as `.window.desktop`), so it no longer silently crops — but don't lean on that guess:
371
+
372
+ - **Full desktop screen** → set `min-height: 900px` (or the source's real height) explicitly. Same advice as the sizing section above.
373
+ - **Component** → size to content; don't reach for `100vh` at all.
374
+
375
+ `px`/`min-height` in, predictable card out. `vh` is the only unit that doesn't mean what you think here.
376
+
354
377
  ### Code & terminal blocks
355
378
 
356
379
  Reach for the built-in **`.code`** (alias **`.terminal`**) class instead of hand-rolling a dark code container — that hand-roll is the single most recurring failure (custom `background:#0f172a` div + base text inheriting `.wrap`'s `light-dark(#111,…)` → invisible in light host mode). The primitive locks bg + ink, re-scopes `color: inherit` to children, and ships the verified github-dark token palette so syntax highlighting reads against `#0f172a` with no per-token tuning: