@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 +15 -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/skills/using-easel/SKILL.md +8 -3
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
|
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.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
|
-
|
|
317
|
-
- **
|
|
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
|
|