@ammduncan/easel 0.4.1 → 0.5.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,21 @@
2
2
 
3
3
  All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 0.5.1 — 2026-05-31
6
+
7
+ ### Changed
8
+ - **Sharpened the `using-easel` skill's `kind:"mockup"` decision rule and added a no-`100vh`-rail height rule** — both targeting recurring app-fidelity authoring bugs. The decision section now leads with the real question — *"is the WHOLE push one full-bleed app screen, or is it prose + embedded specimen(s)?"* — and spells out that a review card / spec sheet / lookbook page (eyebrow + heading + prose + labelled specimen images) is a **presentation**, so `kind` should be left **off**: the prose then lands in the ~56ch reading measure with comfortable side padding, while specimens go in `.full-bleed` and fill the wider content column. Tagging such a card `kind:"mockup"` strips the prose-width cap *and* the body padding, so paragraphs run to the card edge — a bug observed repeatedly in marketing-kit/lookbook cards whose wrapper hand-set only `padding:8px 4px`. A failure-mode callout now documents it, plus the reminder that prose only gets the cap as a direct `body` child or inside `div.wrap`. The new height rule: within a full-screen mockup, never pin an inner rail/sidebar to `100vh`/`min-height:100vh` while the main column is shorter — the rail paints past the content and the self-measured frame inherits the dead band; flex-stretch the shell (`display:flex; align-items:stretch`, no height on the rail) so the rail can only ever be as tall as the tallest column. Docs-only; no runtime change.
9
+
10
+ ## 0.5.0 — 2026-05-29
11
+
12
+ ### Fixed
13
+ - **Exports of pushes built from nested `<iframe srcdoc>` panels (e.g. lookbook specimen grids) are no longer blank.** `html-to-image` can't reach into nested iframes, and the outer push iframe is opaque-origin, so a push composed of nested-iframe panels exported with every panel empty. A capture bridge is now injected into each nested `srcdoc` at wrap time; on export the parent asks each nested frame to render itself (`toSvg`) and composites the results onto the canvas at each frame's measured rect. Lazy nested frames are eagerly loaded before capture, and the export watchdog was extended (30s → 2min) so large multi-panel PNG/PDF renders don't time out.
14
+
15
+ ## 0.4.2 — 2026-05-28
16
+
17
+ ### Added
18
+ - **Pushes now self-check for low-contrast text and stamp a warning chip on the card when they find any.** The #1 recurring author bug — flagged prominently in the push tool description and *still* shipped repeatedly — is a hand-rolled dark code container: author sets `background:#0b0f17` on a custom div but leaves base text inheriting the wrapper's `light-dark(#111, …)` ink, which resolves to near-black against the dark panel in light host mode and the code vanishes (only spans with explicit syntax colours survive). A new in-iframe guard now runs after fonts ready, walks text-bearing elements (bounded at 2000 for cheap), computes WCAG contrast ratio between each one's text colour and its effective background (climbing past transparent ancestors), and posts back `easel:contrast-warn` if any ratio is below 3:1 — well below AA's 4.5:1 floor and the point where text becomes genuinely unreadable. The parent stamps an amber `⚠ contrast` chip on the push's meta row with the offender list in the tooltip (tag, class, ratio, fg-on-bg rgb pair), and the iframe also `console.warn`s the full sample so the offenders surface in DevTools. Symmetric: catches both directions of the bug (dark-on-dark *and* light-on-light hand-rolled containers). The guard is injected into all three render paths (`buildDefaultWrapper` app-fidelity branch, `buildDefaultWrapper` presentation branch, `injectBridge`), and a regression test asserts the injection count so no future render branch can silently skip the check. The right fix is still to reach for the locked-mode primitives (`<div class="code">` / `<div class="terminal">`), and the warning text points authors there. Covered by `tests/unit/contrast-guard.test.mjs`.
19
+
5
20
  ## 0.4.1 — 2026-05-26
6
21
 
7
22
  ### Fixed
@@ -555,6 +555,26 @@ body {
555
555
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
556
556
  }
557
557
 
558
+ /* Contrast warning chip — stamped on a card by stampContrastWarning() after
559
+ the iframe reports text failing WCAG 3:1 against its effective background.
560
+ Amber, not red: this is an author-side smell, not a render error, and the
561
+ push is still visible. Hover for the offender list. */
562
+ .push-warn {
563
+ font-size: 11px;
564
+ padding: 4px 10px;
565
+ border-radius: 999px;
566
+ background: rgba(245, 158, 11, 0.14);
567
+ color: #b45309;
568
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
569
+ letter-spacing: 0.02em;
570
+ cursor: help;
571
+ white-space: nowrap;
572
+ }
573
+ :root[data-theme="dark"] .push-warn {
574
+ background: rgba(245, 158, 11, 0.18);
575
+ color: #fbbf24;
576
+ }
577
+
558
578
  .push-del,
559
579
  .push-export {
560
580
  display: inline-flex;
@@ -11,7 +11,8 @@
11
11
  // Per-push export watchdog timers. If the iframe never posts back
12
12
  // image-ready / image-error (e.g. a render stall), the watchdog clears the
13
13
  // button spinner and surfaces an error instead of spinning forever.
14
- const EXPORT_TIMEOUT_MS = 30000;
14
+ // Generous (2 min) so large/heavy PDF/PNG exports have time to rasterize.
15
+ const EXPORT_TIMEOUT_MS = 120000;
15
16
  const exportWatchdogs = new Map();
16
17
  function clearExportSpinner(pushId) {
17
18
  const iframeEl = cardsEl.querySelector(
@@ -295,6 +296,10 @@
295
296
  }
296
297
  return;
297
298
  }
299
+ if (data.type === "easel:contrast-warn") {
300
+ stampContrastWarning(data.pushId, data.count, data.samples);
301
+ return;
302
+ }
298
303
  if (data.type === "easel:image-error") {
299
304
  console.error("[easel] iframe export error", data);
300
305
  clearExportWatchdog(data.pushId);
@@ -776,6 +781,8 @@
776
781
  if (cleaned.startsWith("<![CDATA[") && cleaned.endsWith("]]>")) {
777
782
  cleaned = cleaned.slice(9, -3).trim();
778
783
  }
784
+ // Make nested iframes capturable on export (no-op when there are none).
785
+ cleaned = injectNestedCaptureBridge(cleaned);
779
786
  const preset = currentPreset();
780
787
  const lower = cleaned.toLowerCase();
781
788
  if (lower.startsWith("<!doctype") || lower.startsWith("<html")) {
@@ -793,6 +800,79 @@
793
800
  );
794
801
  }
795
802
 
803
+ /**
804
+ * Capture bridge injected into every NESTED iframe's srcdoc at wrap time.
805
+ *
806
+ * html-to-image clones the DOM into an SVG <foreignObject>; it cannot reach
807
+ * inside an <iframe> document (and the outer push iframe is opaque-origin, so
808
+ * it can't read nested contentDocuments either — verified empirically). So a
809
+ * push built from nested iframes used to export with every panel blank.
810
+ *
811
+ * This listener lives INSIDE each nested iframe: on `easel:capture-nested` it
812
+ * loads html-to-image (if absent), renders its own visible viewport region to
813
+ * an SVG dataURL, and posts it back tagged with the request token. The outer
814
+ * export bridge then composites these onto the final canvas. Inert until asked.
815
+ */
816
+ function nestedCaptureScript() {
817
+ return `(function(){
818
+ if(window.__easelNestedCapture)return;
819
+ window.__easelNestedCapture=1;
820
+ function reply(m){try{parent.postMessage(m,'*')}catch(_){}}
821
+ function load(cb){
822
+ if(window.htmlToImage)return cb();
823
+ var s=document.createElement('script');
824
+ s.src='https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js';
825
+ s.onload=function(){cb()};
826
+ s.onerror=function(){cb()};
827
+ (document.head||document.documentElement).appendChild(s);
828
+ }
829
+ window.addEventListener('message',function(e){
830
+ var d=e&&e.data;
831
+ if(!d||d.type!=='easel:capture-nested')return;
832
+ var tok=d.token;
833
+ load(function(){
834
+ if(!window.htmlToImage){reply({type:'easel:nested-error',token:tok,message:'html-to-image not loaded'});return}
835
+ var de=document.documentElement;
836
+ var w=de.clientWidth||de.scrollWidth;
837
+ var h=de.clientHeight||de.scrollHeight;
838
+ window.htmlToImage.toSvg(de,{width:w,height:h,cacheBust:true})
839
+ .then(function(u){reply({type:'easel:nested-ready',token:tok,dataUrl:u})})
840
+ .catch(function(err){reply({type:'easel:nested-error',token:tok,message:String(err&&err.message||err)})});
841
+ });
842
+ });
843
+ })();`;
844
+ }
845
+
846
+ /**
847
+ * Append the nested-capture bridge into each `srcdoc="…"` in the pushed HTML
848
+ * so nested iframes can be composited on export. Encodes only `&` and `"`
849
+ * (the attribute delimiter) so it survives regardless of how the author
850
+ * encoded the rest of the srcdoc; the script is appended after the value's
851
+ * existing markup (a <script> after </html> still runs). No-ops when there
852
+ * are no nested iframes, and skips any srcdoc already carrying the bridge.
853
+ */
854
+ function injectNestedCaptureBridge(html) {
855
+ if (!/srcdoc=/i.test(html)) return html;
856
+ // Full entity-encode (matches the common fully-encoded srcdoc style and
857
+ // decodes correctly under minimal-encoded ones too) and insert BEFORE the
858
+ // closing body/html so the listener registers during parse — a trailing
859
+ // <script> after the document end did not reliably execute in sandboxed
860
+ // srcdoc iframes.
861
+ const enc = ("<script>" + nestedCaptureScript() + "</scr" + "ipt>")
862
+ .replace(/&/g, "&amp;")
863
+ .replace(/</g, "&lt;")
864
+ .replace(/>/g, "&gt;")
865
+ .replace(/"/g, "&quot;")
866
+ .replace(/'/g, "&#x27;");
867
+ return html.replace(/srcdoc="([^"]*)"/gi, (m, sd) => {
868
+ if (sd.indexOf("__easelNestedCapture") !== -1) return m;
869
+ let at = sd.lastIndexOf("&lt;/body&gt;");
870
+ if (at === -1) at = sd.lastIndexOf("&lt;/html&gt;");
871
+ const next = at >= 0 ? sd.slice(0, at) + enc + sd.slice(at) : sd + enc;
872
+ return 'srcdoc="' + next + '"';
873
+ });
874
+ }
875
+
796
876
  /**
797
877
  * In-iframe message listener that turns the rendered push into a PNG/JPEG
798
878
  * dataURL on `easel:image` and posts back `easel:image-ready` / `-error`.
@@ -811,31 +891,93 @@
811
891
  */
812
892
  function imageExportScript() {
813
893
  return (
814
- "(function(){var PR=4;" +
894
+ "(function(){" +
895
+ // PIXEL_RATIO 4 for crisp output, but clamp so the biggest canvas side
896
+ // stays under Chrome's safe ceiling — very tall pushes used to blank/throw.
897
+ "function ratio(w,h){var MAX=32760;var big=Math.max(w,h);var pr=4;if(big*pr>MAX){pr=Math.max(1,Math.floor(MAX/big*100)/100)}return pr}" +
815
898
  "function fail(id,fmt,err){console.error('[easel] export failed',err);" +
816
899
  "parent.postMessage({type:'easel:image-error',pushId:id,format:fmt,message:(err&&err.message)?err.message:String(err)},'*')}" +
817
- "function rasterize(svgUrl,w,h,bg,fmt){return new Promise(function(resolve,reject){" +
900
+ // Ask each nested iframe to render itself; resolves to [{iframe,dataUrl}]
901
+ // (dataUrl null on timeout / no bridge, so that panel stays as-is).
902
+ "function captureNested(frames){return Promise.all(frames.map(function(f,i){return new Promise(function(resolve){" +
903
+ "var tok='easelN'+i+'_'+(window.performance&&performance.now?Math.round(performance.now()):i);var done=false;" +
904
+ "function finish(u){if(done)return;done=true;window.removeEventListener('message',onMsg);clearTimeout(t);resolve({iframe:f,dataUrl:u})}" +
905
+ "function onMsg(e){var d=e&&e.data;if(!d||d.token!==tok)return;if(d.type==='easel:nested-ready'){finish(d.dataUrl)}else if(d.type==='easel:nested-error'){finish(null)}}" +
906
+ "window.addEventListener('message',onMsg);var t=setTimeout(function(){finish(null)},9000);" +
907
+ "try{f.contentWindow.postMessage({type:'easel:capture-nested',token:tok},'*')}catch(e){finish(null)}" +
908
+ "})}))}" +
909
+ // Base snapshot → fill bg → draw page → overlay each nested capture at its
910
+ // on-screen rect (SVG stays crisp when scaled into the frame box).
911
+ "function rasterize(svgUrl,w,h,pr,bg,shots){return new Promise(function(resolve,reject){" +
818
912
  "var img=new Image();" +
819
913
  "img.onload=function(){try{var c=document.createElement('canvas');" +
820
- "c.width=Math.max(1,Math.round(w*PR));c.height=Math.max(1,Math.round(h*PR));" +
914
+ "c.width=Math.max(1,Math.round(w*pr));c.height=Math.max(1,Math.round(h*pr));" +
821
915
  "var x=c.getContext('2d');x.fillStyle=bg;x.fillRect(0,0,c.width,c.height);" +
822
916
  "x.drawImage(img,0,0,c.width,c.height);" +
823
- "resolve(fmt==='pdf'?c.toDataURL('image/jpeg',1.0):c.toDataURL('image/png'))}catch(e){reject(e)}};" +
917
+ "var pend=shots.filter(function(s){return s.dataUrl});if(!pend.length){return resolve(c)}" +
918
+ "var left=pend.length;" +
919
+ "pend.forEach(function(s){var r=s.iframe.getBoundingClientRect();var ni=new Image();" +
920
+ "ni.onload=function(){try{x.drawImage(ni,(r.left+window.scrollX)*pr,(r.top+window.scrollY)*pr,r.width*pr,r.height*pr)}catch(_){}if(--left===0)resolve(c)};" +
921
+ "ni.onerror=function(){if(--left===0)resolve(c)};ni.src=s.dataUrl})" +
922
+ "}catch(e){reject(e)}};" +
824
923
  "img.onerror=function(){reject(new Error('SVG snapshot failed to load'))};img.src=svgUrl})}" +
825
924
  "function run(d){var id=d.pushId;var fn=d.filename||'push.png';var fmt=d.format==='pdf'?'pdf':'png';" +
826
925
  "var bg=d.bgColor||getComputedStyle(document.documentElement).getPropertyValue('--ds-bg-elev').trim()||'#ffffff';" +
827
926
  "function render(){if(!window.htmlToImage){fail(id,fmt,new Error('html-to-image not loaded'));return}" +
927
+ "var frames=Array.prototype.slice.call(document.querySelectorAll('iframe'));" +
928
+ // Force lazy nested iframes to load before capture, else off-screen panels
929
+ // export blank. Settle briefly so their srcdoc bridge is live, then capture.
930
+ "frames.forEach(function(f){try{f.loading='eager'}catch(_){}});" +
828
931
  "var w=document.documentElement.clientWidth;" +
829
932
  "var h=Math.max(document.documentElement.scrollHeight,document.body?document.body.scrollHeight:0);" +
830
- "window.htmlToImage.toSvg(document.documentElement,{width:w,height:h,cacheBust:true})" +
831
- ".then(function(u){return rasterize(u,w,h,bg,fmt)})" +
832
- ".then(function(u){parent.postMessage({type:'easel:image-ready',pushId:id,dataUrl:u,filename:fn,format:fmt},'*')})" +
933
+ "var pr=ratio(w,h);" +
934
+ "new Promise(function(res){setTimeout(res,frames.length?700:0)}).then(function(){return captureNested(frames)}).then(function(shots){" +
935
+ "return window.htmlToImage.toSvg(document.documentElement,{width:w,height:h,cacheBust:true})" +
936
+ ".then(function(u){return rasterize(u,w,h,pr,bg,shots)})})" +
937
+ ".then(function(c){var u=fmt==='pdf'?c.toDataURL('image/jpeg',1.0):c.toDataURL('image/png');" +
938
+ "parent.postMessage({type:'easel:image-ready',pushId:id,dataUrl:u,filename:fn,format:fmt},'*')})" +
833
939
  ".catch(function(e){fail(id,fmt,e)})}" +
834
940
  "if(document.fonts&&document.fonts.ready){document.fonts.ready.then(render).catch(render)}else{render()}}" +
835
941
  "window.addEventListener('message',function(e){if(e&&e.data&&e.data.type==='easel:image')run(e.data)})})();"
836
942
  );
837
943
  }
838
944
 
945
+ /**
946
+ * In-iframe contrast guard. Scans rendered text-bearing elements and flags
947
+ * any whose computed text colour fails a WCAG contrast ratio of 3:1 against
948
+ * its effective background (climbs ancestors past transparent fills).
949
+ *
950
+ * Catches BOTH directions of the recurring locked-mode bug from Rule 30:
951
+ * - dark text on a hand-rolled dark code container (the #1 case — author
952
+ * skipped the .code/.terminal primitive and base text inherits a
953
+ * light-mode ink against #0f172a)
954
+ * - light text on a hand-rolled bright container
955
+ *
956
+ * Runs once after fonts ready + a 400ms settle. Bounded at 2000 text-bearing
957
+ * elements scanned to stay cheap on large pushes. Posts a single
958
+ * easel:contrast-warn to the parent with up to 5 offender samples; parent
959
+ * stamps a chip on the card so the author actually notices.
960
+ *
961
+ * Shared verbatim by buildDefaultWrapper (both branches) and injectBridge.
962
+ */
963
+ function contrastGuardScript(pushId) {
964
+ return (
965
+ "(function(){var ID=" +
966
+ JSON.stringify(pushId) +
967
+ ";function parseColor(c){if(!c)return null;var m=c.match(/rgba?\\(([^)]+)\\)/);if(!m)return null;var p=m[1].split(',').map(function(s){return parseFloat(s.trim())});if(p.length<3||p.some(isNaN))return null;return{r:p[0],g:p[1],b:p[2],a:p.length>3?p[3]:1}}" +
968
+ "function lum(c){function ch(v){v/=255;return v<=0.03928?v/12.92:Math.pow((v+0.055)/1.055,2.4)}return 0.2126*ch(c.r)+0.7152*ch(c.g)+0.0722*ch(c.b)}" +
969
+ "function contrast(a,b){var la=lum(a),lb=lum(b);var hi=Math.max(la,lb),lo=Math.min(la,lb);return(hi+0.05)/(lo+0.05)}" +
970
+ "function effBg(el){var n=el;while(n&&n.nodeType===1){var cs=getComputedStyle(n);var bg=parseColor(cs.backgroundColor);if(bg&&bg.a>0.05)return bg;n=n.parentElement}return{r:255,g:255,b:255,a:1}}" +
971
+ "function hasDirectText(el){for(var i=0;i<el.childNodes.length;i++){var n=el.childNodes[i];if(n.nodeType===3&&n.nodeValue.trim().length>0)return true}return false}" +
972
+ "function fmt(c){return'rgb('+Math.round(c.r)+','+Math.round(c.g)+','+Math.round(c.b)+')'}" +
973
+ "function scan(){if(!document.body)return;var offenders=[];var seen=0;var all=document.body.querySelectorAll('*');for(var i=0;i<all.length&&seen<2000;i++){var el=all[i];if(!hasDirectText(el))continue;seen++;var cs=getComputedStyle(el);if(cs.visibility==='hidden'||cs.display==='none')continue;var fg=parseColor(cs.color);if(!fg||fg.a<0.05)continue;var bg=effBg(el);var ratio=contrast(fg,bg);if(ratio<3){offenders.push({tag:el.tagName.toLowerCase(),cls:(el.className&&el.className.toString?el.className.toString():'').slice(0,80),text:(el.textContent||'').trim().slice(0,60),ratio:Math.round(ratio*100)/100,fg:fmt(fg),bg:fmt(bg)})}}" +
974
+ "if(offenders.length){console.warn('[easel] low-contrast text detected ('+offenders.length+' element(s), threshold 3:1). The #1 cause is a hand-rolled dark code container — use <div class=\"code\"> or <div class=\"terminal\"> instead; they lock background AND ink and re-scope color:inherit to children. Offenders:',offenders.slice(0,10));try{parent.postMessage({type:'easel:contrast-warn',pushId:ID,count:offenders.length,samples:offenders.slice(0,5)},'*')}catch(e){}}}" +
975
+ "function run(){setTimeout(scan,400)}" +
976
+ "if(document.fonts&&document.fonts.ready){document.fonts.ready.then(run).catch(run)}else{run()}" +
977
+ "})();"
978
+ );
979
+ }
980
+
839
981
  function buildDefaultWrapper(body, theme, preset, pushId, appFidelity) {
840
982
  const density = currentDensity();
841
983
  // app-fidelity mode: skip presentation defaults (presets, semantic chips,
@@ -860,6 +1002,7 @@ ${STRUCTURAL_PRIMITIVES_CSS}
860
1002
  <body>
861
1003
  ${body}
862
1004
  <script>${imageExportScript()}</script>
1005
+ <script>${contrastGuardScript(pushId)}</script>
863
1006
  <script>${selfMeasureScript(pushId)}</script>
864
1007
  </body>
865
1008
  </html>`;
@@ -1076,6 +1219,7 @@ ${body}
1076
1219
  })();
1077
1220
  </script>
1078
1221
  <script>${imageExportScript()}</script>
1222
+ <script>${contrastGuardScript(pushId)}</script>
1079
1223
  <script>${selfMeasureScript(pushId)}</script>
1080
1224
  </body>
1081
1225
  </html>`;
@@ -1088,8 +1232,9 @@ ${body}
1088
1232
  JSON.stringify({ theme, preset, density }) +
1089
1233
  ");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(_){}}})})();</script>";
1090
1234
  const imageScript = "<script>" + imageExportScript() + "</script>";
1235
+ const guardScript = "<script>" + contrastGuardScript(pushId) + "</script>";
1091
1236
  const measureScript = "<script>" + selfMeasureScript(pushId) + "</script>";
1092
- const combined = configScript + imageScript + measureScript;
1237
+ const combined = configScript + imageScript + guardScript + measureScript;
1093
1238
  if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, combined + "</body>");
1094
1239
  return html + combined;
1095
1240
  }
@@ -1108,6 +1253,35 @@ ${body}
1108
1253
  return { wasNearBottom, card };
1109
1254
  }
1110
1255
 
1256
+ /**
1257
+ * Stamp a "contrast" warning chip on a push card's meta row after the
1258
+ * iframe reports low-contrast text. Idempotent: only inserts once per push.
1259
+ * Tooltip lists the first few offenders so the author can locate them
1260
+ * without opening DevTools. The console.warn fires inside the iframe too,
1261
+ * so DevTools still shows the full sample list and computed rgb pairs.
1262
+ */
1263
+ function stampContrastWarning(pushId, count, samples) {
1264
+ if (!pushId) return;
1265
+ const card = document.getElementById("push-" + pushId);
1266
+ if (!card) return;
1267
+ const meta = card.querySelector(".push-meta");
1268
+ if (!meta || meta.querySelector(".push-warn")) return;
1269
+ const chip = document.createElement("span");
1270
+ chip.className = "push-warn";
1271
+ chip.textContent = "⚠ contrast";
1272
+ const head = `${count} low-contrast element(s) detected (WCAG ratio < 3:1).\nMost common cause: a hand-rolled dark code container — use class="code" or class="terminal".\n`;
1273
+ const list = (samples || [])
1274
+ .map((s) => `• <${s.tag}${s.cls ? "." + s.cls.split(/\s+/).join(".") : ""}> "${s.text}" (${s.ratio}:1, ${s.fg} on ${s.bg})`)
1275
+ .join("\n");
1276
+ chip.title = head + list;
1277
+ const time = meta.querySelector(".push-time");
1278
+ if (time) {
1279
+ meta.insertBefore(chip, time);
1280
+ } else {
1281
+ meta.appendChild(chip);
1282
+ }
1283
+ }
1284
+
1111
1285
  function removeCardFromDom(pushId) {
1112
1286
  const card = document.getElementById("push-" + pushId);
1113
1287
  if (card) card.remove();
package/dist/mcp.js CHANGED
@@ -127,7 +127,8 @@ export async function main() {
127
127
  "It locks BOTH background (#0f172a) and ink (#e6edf3), re-scopes `color:inherit` to every child, and provides verified github-dark syntax token classes you can drop onto spans — NO per-token tuning needed:\n" +
128
128
  " .kw (keywords #ff7b72) · .string (#a5d6ff) · .fn (function #d2a8ff) · .prop (identifiers #79c0ff) · .num (#ffa657) · .comment (#8b949e) · .muted (#94a3b8) · .accent (#6ee7b7)\n" +
129
129
  " e.g. <div class=\"code\"><span class=\"kw\">gcloud</span> services enable run.googleapis.com</div>\n" +
130
- "Plain <pre>/<code> are also already safe (bg+ink token pair). Only reach for a fully custom container when .code/.terminal genuinely don't fit — and then obey the locked-mode rule below.\n\n" +
130
+ "Plain <pre>/<code> are also already safe (bg+ink token pair). Only reach for a fully custom container when .code/.terminal genuinely don't fit — and then obey the locked-mode rule below.\n" +
131
+ "SAFETY NET: every push self-checks for low-contrast text (WCAG <3:1) after render. If your push trips it, the card gets an amber `⚠ contrast` chip on the meta row with the offender list in the tooltip, and the iframe console.warns with sample rgb pairs. Treat the chip as a build-failure-equivalent — the fix is almost always swapping the hand-rolled container for `.code` / `.terminal` (or, for a non-code locked-bg container, applying the locked-mode rule below).\n\n" +
131
132
  "═══ COPY-PASTE STARTER (any OTHER LOCKED-MODE container — brand hero, custom panel) ═══\n" +
132
133
  "If a container has a FIXED background (not `light-dark()`), you MUST set its own text color AND re-scope `color: inherit` to its children. Otherwise the children inherit `light-dark(...)` from `.wrap` and the text flips to the wrong shade in one mode (e.g. dark text on a locked-dark panel in light host mode → invisible).\n" +
133
134
  " .hero { background: #0f172a; color: #e6edf3; border-radius: 12px; padding: 20px 24px; }\n" +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ammduncan/easel",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -210,6 +210,8 @@ A mockup's height should match what it actually represents — don't fake it tal
210
210
 
211
211
  **Mocking a full desktop screen / page** (the whole viewport — a login screen, a dashboard, a settings page): give it **realistic desktop viewport proportions**, because the surrounding space *is* part of how that screen looks. A login form genuinely sits in a ~720–800 px-tall viewport with the panel centred — cropping that down to just the form's content height misrepresents it as much as over-padding a component does. Use **`min-height`** for the floor (e.g. `min-height: 760px` or a 16:10 box) — **never a fixed `height`**, which clips anything taller — and lay the content out inside it the way the real screen does (centred form, top nav, sidebar full-height, etc.). This mirrors the wrapper's own `.window.desktop`, which is `min-height: 900px`, not `height`.
212
212
 
213
+ **Within a full-screen mockup, size the SHELL to content — never pin an inner rail/sidebar to the viewport.** A common miss: the screen is a sidebar + main column, and the sidebar (or a hero rail) gets `height: 100vh` / `min-height: 100vh` while the main column is shorter. The rail then paints far past the content, and the iframe's self-measure inherits that dead band — a tall dark strip below the real UI. **Fix:** make the shell a flex row and let the rail **stretch to content** — `display: flex; align-items: stretch` (the default) with **no height on the rail** — so the rail can only ever be as tall as the tallest column. Put any `min-height` floor on the *outer* screen if you want viewport proportions; never on an inner rail. (This was a real bug: a Ledger dashboard mock's dark rail was pinned to `100vh` against short content; the rebuilt version with flex-stretch measured rail-height === main-height exactly, band gone.)
214
+
213
215
  So the rule is **faithful height, not minimal height** — but always expressed as `min-height`, never a fixed `height`:
214
216
  - Component → content height (no padding to a fake screen size).
215
217
  - Full screen → real viewport proportions via `min-height` (don't crop to content, but don't cap it with a fixed `height` either).
@@ -312,9 +314,12 @@ Most mockups appear *inside* an explanation push — prose intro, embedded UI mo
312
314
 
313
315
  `.full-bleed` is injected into every presentation push. Prose is left-aligned and capped at ~880px; `.full-bleed` fills the **content column's full width from the same left edge** — wider than the prose, sharing one left margin down the card. It does *not* bleed to the card's physical edge: the body padding stays as a gutter, so neither the mockup nor the text ever touches the card border.
314
316
 
315
- Two cases, two tools:
316
- - **Whole push is a mockup / app recreation** → `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.
317
- - **Mockup embedded in an explanation** → leave the push as-is and wrap the mockup section in `<div class="full-bleed">`.
317
+ Two cases, two tools. **The deciding question is NOT "does this push contain a mockup?" — it's "is the WHOLE push one full-bleed app screen, or is it prose + embedded specimen(s)?"** A review card / spec sheet / lookbook page with an eyebrow + heading + paragraphs and one-or-more labelled mockup images is the **second** kind, even though it "contains mockups" — it's a presentation, not an app recreation. Default to leaving `kind` off; reach for `kind:"mockup"` only when the push is *nothing but* the UI.
318
+
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
+ - **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
+
322
+ > **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.
318
323
 
319
324
  ### Window chrome for UI mockups
320
325