@ammduncan/easel 0.5.1 → 0.6.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 +8 -0
- package/dist/client/viewer.js +83 -2
- package/dist/mcp.js +2 -0
- package/package.json +1 -1
- package/skills/using-easel/SKILL.md +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 0.6.0 — 2026-06-03
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **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).
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **`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.
|
|
12
|
+
|
|
5
13
|
## 0.5.1 — 2026-05-31
|
|
6
14
|
|
|
7
15
|
### Changed
|
package/dist/client/viewer.js
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
|
|
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" +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ammduncan/easel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -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:
|