@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 +5 -0
- package/dist/client/viewer.css +20 -0
- package/dist/client/viewer.js +183 -9
- package/dist/mcp.js +2 -1
- package/package.json +1 -1
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
|
package/dist/client/viewer.css
CHANGED
|
@@ -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;
|
package/dist/client/viewer.js
CHANGED
|
@@ -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
|
-
|
|
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, "&")
|
|
863
|
+
.replace(/</g, "<")
|
|
864
|
+
.replace(/>/g, ">")
|
|
865
|
+
.replace(/"/g, """)
|
|
866
|
+
.replace(/'/g, "'");
|
|
867
|
+
return html.replace(/srcdoc="([^"]*)"/gi, (m, sd) => {
|
|
868
|
+
if (sd.indexOf("__easelNestedCapture") !== -1) return m;
|
|
869
|
+
let at = sd.lastIndexOf("</body>");
|
|
870
|
+
if (at === -1) at = sd.lastIndexOf("</html>");
|
|
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(){
|
|
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
|
-
|
|
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*
|
|
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
|
-
"
|
|
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
|
-
"
|
|
831
|
-
".then(function(
|
|
832
|
-
".
|
|
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
|
|
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.
|
|
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",
|