📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/* ============================================================================
|
||||
layout-audit.js — deterministic layout metrics that MEDIATE the eye.
|
||||
|
||||
Run via Playwright MCP `browser_evaluate` on a rendered page. It does TWO things:
|
||||
1. returns a compact JSON of measured findings (geometry, contrast, tap, balance)
|
||||
2. DRAWS those findings as an SVG overlay onto the page, so the very next
|
||||
`browser_take_screenshot` is an ANNOTATED screenshot.
|
||||
|
||||
The annotated screenshot is the whole point. You MUST view it and reason over the
|
||||
picture + the numbers together — the metric tells you WHERE to look; your eyes
|
||||
decide whether each flag is a real defect or an intentional choice (an avatar on a
|
||||
banner "collides"; a brutalist overlap is the design). NEVER auto-fail on a count,
|
||||
and never accept a metric you haven't looked at. The number points; the eye decides.
|
||||
|
||||
Usage (Playwright MCP):
|
||||
browser_evaluate({ function: "() => { <paste this whole file> ; return __audit({}); }" })
|
||||
browser_take_screenshot() // <-- now shows the overlay; LOOK at it
|
||||
Options (pass to __audit):
|
||||
{ align: '.card .title, .card .price', // selector whose left edges SHOULD align
|
||||
space: '.feature-list', // container whose children's vertical rhythm to check
|
||||
draw: true } // set false to measure without drawing
|
||||
Re-run __clearAudit() to remove the overlay.
|
||||
============================================================================ */
|
||||
function __audit(opts){
|
||||
opts = opts || {};
|
||||
const DRAW = opts.draw !== false;
|
||||
const COL = {collision:'#e0245e',align:'#d98a00',spacing:'#2f7fd6',contrast:'#8a2be2',tap:'#159a4d',balance:'#111'};
|
||||
const vis = el => { const s=getComputedStyle(el); return s.display!=='none'&&s.visibility!=='hidden'&&el.getClientRects().length>0; };
|
||||
const R = el => { const r=el.getBoundingClientRect();
|
||||
return {el,x:r.left,y:r.top,w:r.width,h:r.height,r:r.right,b:r.bottom,cx:r.left+r.width/2,cy:r.top+r.height/2}; };
|
||||
|
||||
// ---- content set: visible, sized, leaf-ish "ink" elements (skip layout wrappers) ----
|
||||
const CONTENT = (opts.contentSelector
|
||||
? [...document.querySelectorAll(opts.contentSelector)]
|
||||
: [...document.querySelectorAll('h1,h2,h3,h4,p,li,img,svg,button,a,input,label,figure,blockquote,[class*="card"],[class*="tile"],[class*="btn"],[class*="hero"],[class*="banner"],[class*="panel"],[class*="badge"],[class*="chip"],[class*="thumb"],[class*="avatar"]')])
|
||||
.filter(vis).map(R).filter(it=>it.w>=8&&it.h>=8&&it.w<innerWidth*1.5);
|
||||
|
||||
// 1. COLLISION — content rects overlapping ≥12% of the smaller (skip ancestor/descendant)
|
||||
const collisions=[];
|
||||
for(let i=0;i<CONTENT.length;i++)for(let j=i+1;j<CONTENT.length;j++){
|
||||
const a=CONTENT[i],b=CONTENT[j];
|
||||
if(a.el.contains(b.el)||b.el.contains(a.el))continue;
|
||||
const ix=Math.max(0,Math.min(a.r,b.r)-Math.max(a.x,b.x)), iy=Math.max(0,Math.min(a.b,b.b)-Math.max(a.y,b.y));
|
||||
if(ix*iy<=0)continue;
|
||||
if(ix*iy/Math.min(a.w*a.h,b.w*b.h)>0.12) collisions.push([a,b]);
|
||||
}
|
||||
|
||||
// 2. ALIGNMENT near-miss — only meaningful when scoped to elements that SHOULD share a line
|
||||
let nearmiss=[], guides=[];
|
||||
if(opts.align){
|
||||
const els=[...document.querySelectorAll(opts.align)].filter(vis).map(R).sort((a,b)=>a.x-b.x);
|
||||
let cur=[]; const cl=[];
|
||||
els.forEach(e=>{ if(cur.length&&e.x-cur[cur.length-1].x>11){cl.push(cur);cur=[];} cur.push(e); });
|
||||
if(cur.length)cl.push(cur);
|
||||
cl.forEach(c=>{ if(c.length<2)return;
|
||||
const xs=c.map(e=>e.x).sort((a,b)=>a-b), med=xs[(xs.length/2)|0], spread=xs[xs.length-1]-xs[0];
|
||||
guides.push(med); if(spread<=0.8||spread>10)return;
|
||||
c.forEach(e=>{ if(Math.abs(e.x-med)>0.8) nearmiss.push({x:e.x,med,y:e.y,b:e.b,cy:e.cy,off:Math.round(e.x-med)}); });
|
||||
});
|
||||
}
|
||||
|
||||
// 3. SPACING rhythm — vertical gaps among a container's direct children
|
||||
let spacing={gaps:[],cov:0,outliers:[]};
|
||||
if(opts.space){ const cont=document.querySelector(opts.space);
|
||||
if(cont){ const kids=[...cont.children].filter(vis).map(R).sort((a,b)=>a.y-b.y), gaps=[];
|
||||
for(let i=0;i<kids.length-1;i++){const g=kids[i+1].y-kids[i].b; if(g>-6&&g<400)gaps.push({g:Math.round(g),a:kids[i],b:kids[i+1]});}
|
||||
if(gaps.length>=2){ const v=gaps.map(o=>o.g),mn=v.reduce((s,x)=>s+x,0)/v.length,
|
||||
sd=Math.sqrt(v.reduce((s,x)=>s+(x-mn)**2,0)/v.length);
|
||||
spacing={gaps,cov:mn?Math.abs(sd/mn):0,mean:mn,outliers:gaps.filter(o=>Math.abs(o.g-mn)>Math.max(6,Math.abs(mn)*0.5))}; }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. CONTRAST — WCAG ratio of text vs effective background
|
||||
const rgb=s=>{const m=(s||'').match(/rgba?\(([^)]+)\)/);if(!m)return null;const p=m[1].split(',').map(parseFloat);return{r:p[0],g:p[1],b:p[2],a:p[3]??1};};
|
||||
const ebg=el=>{let n=el;while(n&&n.nodeType===1){const c=rgb(getComputedStyle(n).backgroundColor);if(c&&c.a>0.5)return c;n=n.parentElement;}return{r:255,g:255,b:255,a:1};};
|
||||
const lum=({r,g,b})=>{const f=v=>{v/=255;return v<=0.03928?v/12.92:((v+0.055)/1.055)**2.4;};return .2126*f(r)+.7152*f(g)+.0722*f(b);};
|
||||
const ratio=(a,b)=>{const L1=lum(a),L2=lum(b),hi=Math.max(L1,L2),lo=Math.min(L1,L2);return (hi+.05)/(lo+.05);};
|
||||
const contrast=[];
|
||||
[...document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,a,span,li,button,label,td,th,figcaption,small,strong,em')].filter(vis).forEach(el=>{
|
||||
const t=(el.childNodes.length&&[...el.childNodes].some(n=>n.nodeType===3&&n.textContent.trim()));if(!t)return;
|
||||
const cs=getComputedStyle(el),fg=rgb(cs.color);if(!fg)return;
|
||||
const ra=ratio(fg,ebg(el)),px=parseFloat(cs.fontSize),wt=parseInt(cs.fontWeight)||400,large=px>=24||(px>=18.66&&wt>=700);
|
||||
if(ra < (large?3:4.5)){const r=R(el);contrast.push({x:r.x,y:r.y,w:r.w,h:r.h,ratio:Math.round(ra*10)/10,large});}
|
||||
});
|
||||
|
||||
// 5. TAP — interactive targets under 44×44 (Apple HIG)
|
||||
const tap=[...document.querySelectorAll('a,button,[role="button"],input:not([type="hidden"]),select,textarea,[onclick]')]
|
||||
.filter(vis).map(R).filter(it=>it.w<44||it.h<44);
|
||||
|
||||
// 6. BALANCE — ink-density-weighted centroid (box-model ESTIMATE) vs optical center (0.50,0.46)
|
||||
// NOTE: this is the cheap model. For ground truth, screenshot and run a pixel-centroid (see SKILL §6).
|
||||
const DENS={H1:.82,H2:.62,H3:.5,H4:.45,P:.16,LI:.16,IMG:.5,SVG:.5,BUTTON:.7,A:.4};
|
||||
const dens=el=>el.tagName in DENS?DENS[el.tagName]:0.3;
|
||||
let W=0,Mx=0,My=0; const pw=document.documentElement.scrollWidth, ph=document.documentElement.scrollHeight;
|
||||
CONTENT.forEach(it=>{const w=it.w*it.h*dens(it.el);W+=w;Mx+=w*(it.cx+scrollX);My+=w*(it.cy+scrollY);});
|
||||
const balance = W?{cx:Mx/W/pw,cy:My/W/ph}:{cx:.5,cy:.46};
|
||||
balance.off=Math.hypot(balance.cx-0.5,balance.cy-0.46);
|
||||
|
||||
// 7. OVERFLOW — horizontal scroll (the §4 gate)
|
||||
const overflow=Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
|
||||
// ---- draw overlay (this is what makes the next screenshot annotated) ----
|
||||
__clearAudit();
|
||||
if(DRAW){
|
||||
const NS='http://www.w3.org/2000/svg', svg=document.createElementNS(NS,'svg');
|
||||
svg.id='__auditOverlay';
|
||||
Object.assign(svg.style,{position:'fixed',inset:'0',width:'100vw',height:'100vh',zIndex:2147483647,pointerEvents:'none'});
|
||||
const add=(t,a)=>{const e=document.createElementNS(NS,t);for(const k in a)e.setAttribute(k,a[k]);if(a._t)e.textContent=a._t;svg.appendChild(e);};
|
||||
collisions.forEach(([a,b])=>[a,b].forEach(it=>add('rect',{x:it.x,y:it.y,width:it.w,height:it.h,fill:COL.collision+'22',stroke:COL.collision,'stroke-width':2.5})));
|
||||
guides.forEach(x=>add('line',{x1:x,y1:0,x2:x,y2:innerHeight,stroke:COL.align,'stroke-width':1,'stroke-dasharray':'6 5','opacity':.6}));
|
||||
nearmiss.forEach(n=>{add('line',{x1:n.x,y1:n.y,x2:n.x,y2:n.b,stroke:COL.align,'stroke-width':3});add('text',{x:n.x+8,y:n.cy+4,fill:COL.align,'font-family':'monospace','font-size':13,_t:(n.off>=0?'+':'')+n.off+'px'});});
|
||||
spacing.gaps.forEach(o=>{const bad=spacing.outliers.includes(o),x=Math.min(o.a.x,o.b.x)-16;add('line',{x1:x,y1:o.a.b,x2:x,y2:o.b.y,stroke:bad?COL.collision:COL.spacing,'stroke-width':bad?3.5:2});add('text',{x:x-7,y:(o.a.b+o.b.y)/2+4,fill:bad?COL.collision:COL.spacing,'font-family':'monospace','font-size':13,'text-anchor':'end',_t:o.g});});
|
||||
contrast.forEach(c=>{add('rect',{x:c.x-2,y:c.y-2,width:c.w+4,height:c.h+4,fill:'none',stroke:COL.contrast,'stroke-width':2,'stroke-dasharray':'3 3'});add('rect',{x:c.x,y:c.y-19,width:64,height:17,fill:COL.contrast});add('text',{x:c.x+4,y:c.y-6,fill:'#fff','font-family':'monospace','font-size':12,_t:c.ratio+':1 ✗'});});
|
||||
tap.forEach(it=>{add('rect',{x:it.x,y:it.y,width:it.w,height:it.h,fill:'none',stroke:COL.tap,'stroke-width':2.5});add('text',{x:it.x,y:it.b+13,fill:COL.tap,'font-family':'monospace','font-size':12,_t:Math.round(it.w)+'×'+Math.round(it.h)});});
|
||||
document.body.appendChild(svg);
|
||||
}
|
||||
|
||||
// ---- JSON summary, split by KIND. The metric is never a verdict on its own. ----
|
||||
return {
|
||||
// GATES — correctness (accessibility/usability facts). Safe to block on; gates_pass must hold.
|
||||
gates: { overflow_px: overflow, contrast_fails: contrast.length, tap_too_small: tap.length },
|
||||
gates_pass: overflow===0 && contrast.length===0 && tap.length===0,
|
||||
// REVIEW — usually a real defect, but can be intentional (deliberate overlap). Eye-confirm; do NOT auto-fail.
|
||||
collisions: collisions.length,
|
||||
// SIGNALS — convention, NOT correctness. High score = closer to the symmetric/regular MEAN (§2).
|
||||
// Deviation is often the better design. Use to catch accidents; never auto-correct toward symmetry.
|
||||
signals: { balance_offset:+balance.off.toFixed(3), balance_centroid:[+balance.cx.toFixed(3),+balance.cy.toFixed(3)],
|
||||
align_nearmiss: nearmiss.length, spacing_cov:+spacing.cov.toFixed(2) },
|
||||
contrast_detail: contrast.map(c=>({ratio:c.ratio, large:c.large})),
|
||||
note: 'These are HEURISTICS. (1) LOOK at the annotated screenshot — a flag marks WHERE to look, never a verdict. '
|
||||
+ '(2) GATES (overflow/contrast/tap) are correctness — fix them. (3) SIGNALS (balance/alignment/spacing) measure '
|
||||
+ 'CONVENTION = the generic mean; off-center balance, deliberate misalignment, uneven rhythm are creative tools — '
|
||||
+ 'do NOT "fix" a signal toward symmetry unless the eye judges the deviation worse. Maximizing signals designs the mean. '
|
||||
+ '(4) collisions: usually a bug, sometimes intentional (overlap aesthetics) — eye-confirm. '
|
||||
+ 'gates_pass is necessary, not sufficient; a passing layout can still be generic.'
|
||||
};
|
||||
}
|
||||
function __clearAudit(){ const o=document.getElementById('__auditOverlay'); if(o)o.remove(); }
|
||||
Reference in New Issue
Block a user