Files
playbook/antigravity-awesome-skills/skills/design-spatial/scripts/layout-audit.js
T
2026-07-01 16:02:41 +00:00

139 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
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(); }