@ammduncan/easel 0.4.1 → 0.5.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,11 @@
2
2
 
3
3
  All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 0.4.2 — 2026-05-28
6
+
7
+ ### Added
8
+ - **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`.
9
+
5
10
  ## 0.4.1 — 2026-05-26
6
11
 
7
12
  ### 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.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",