/* ============================================================================ 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: "() => { ; 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.w0.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-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(); }