📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
---
|
||||
name: design-spatial
|
||||
description: Design — spatial composition
|
||||
risk: unknown
|
||||
source: https://github.com/connerkward/ckw-design-skill/tree/main/deterministic-design/design-spatial
|
||||
source_repo: connerkward/ckw-design-skill
|
||||
source_type: community
|
||||
date_added: 2026-07-01
|
||||
license: MIT
|
||||
license_source: https://github.com/connerkward/ckw-design-skill/blob/main/LICENSE
|
||||
---
|
||||
|
||||
# Design — spatial composition
|
||||
## When to Use
|
||||
|
||||
Use this skill when you need design — spatial composition.
|
||||
|
||||
|
||||
A model cannot trust its own UI output. Everything else follows from two failures.
|
||||
|
||||
## 1. It can't see what it made
|
||||
|
||||
UI is generated as a token stream, never as pixels — so the model cannot perceive collisions, overlap, imbalance, or broken spacing. It will write a headline that runs into the hero image and have no idea.
|
||||
|
||||
**Render it and judge the image, not the code.** Serve with any static server (e.g. `python3 -m http.server` or `npx serve`) and screenshot headless via Playwright. Screenshot at a few widths.
|
||||
|
||||
**Critique with fresh eyes — not your own.** Grading your own output rationalizes it; the builder looks at its overlapping headline and calls it fine (this is exactly how a real collision shipped in testing). Use a separate judge — a subagent that did *not* write the page — and tell it to hunt for what's *wrong*: collisions, edge tangents, ragged alignment, lopsided weight, no clear focal point, breaks at some width. Fix, re-render, re-judge.
|
||||
|
||||
## 2. Its first idea is the average
|
||||
|
||||
Whatever it produces first is the mean of its training data — and there is more than one mean:
|
||||
|
||||
- the **generic-AI mean**: Inter, purple-on-white gradients, centered single column, three equal cards;
|
||||
- the **designer-trend mean**: oversized condensed caps, dark-mode + grain, monospace "vibes" microtext, sticker badges.
|
||||
|
||||
Landing on the second isn't taste — it's a more flattering average, which is why it slips past. **Treat your first instinct as the mean and deviate deliberately — toward *this product's specific world*** (use design-thinking's domain / color-world / signature as the direction), **not toward another trend.** If the result could be any startup, you shipped the mean.
|
||||
|
||||
## 3. So don't prescribe a style
|
||||
|
||||
Any fixed rule — a 12-col grid, an 8-point scale, "mono = data" — *becomes* next cycle's mean, and a blind model executes it into collisions anyway. Prescribe the **process, not the look**: see it with fresh eyes, and push off the average toward the domain. Taste supplies the direction (design-thinking / design-philosophy); this skill only insists you **look** and **don't ship the mean**.
|
||||
|
||||
For iterative spatial tuning, a local page with live controls (sliders, pickers, drag handles) beats one-shot critique.
|
||||
|
||||
## 4. NEVER ship horizontal overflow — THE mandatory gate, no exceptions
|
||||
|
||||
> **BLOCKING GATE. You may not call any web UI "done", "working", "fixed", or
|
||||
> "looks good" until you have run the `scrollWidth` check below at a narrow width
|
||||
> THIS turn and seen `0`. Not "I added overflow-x:clip so it's fine." Not "it
|
||||
> looked fine at my width." MEASURE. Narrow. Every time. If you didn't measure,
|
||||
> it isn't done — say "haven't checked overflow yet" instead of claiming done.**
|
||||
|
||||
A side-to-side scrollbar that doesn't match the content is the **single most common
|
||||
and most embarrassing** layout failure, and it ships *over and over* because the dev
|
||||
viewport is wide enough to hide it — the overflow only appears once the window is
|
||||
narrower than some element. It is **invisible at desktop width**, so the §1
|
||||
render-critique loop will NOT catch it unless you screenshot narrow. Separate,
|
||||
explicit, non-negotiable gate.
|
||||
|
||||
**It recurs because layouts GROW after they were last checked.** Every time you add a
|
||||
nav tab, a toolbar button, a header control, a chip, a wider equation/`<pre>`, or any
|
||||
new item to a `flex`/`inline` row, you have invalidated the last overflow check — the
|
||||
row that fit yesterday now pushes past the edge between ~720–1200px while your 1440px
|
||||
dev window shows nothing wrong. (Real ship, 2026-06, TWICE: a progress-bar edge label
|
||||
overflowed 23px; then a `flex-wrap:nowrap` header that grew 4 tabs scrolled the whole
|
||||
page 309px across 720–1200px — both invisible at dev width, both caught only by
|
||||
measuring narrow.) **So: any change that adds an element to a horizontal row re-arms
|
||||
this gate. Re-measure.**
|
||||
|
||||
**Default defenses to apply up front (so the gate passes by construction):**
|
||||
- **Header / nav / toolbar rows: `flex-wrap: wrap`, never `nowrap`.** A growing
|
||||
single-row flex is the #1 source of this bug. Wrapping is a no-op when it fits and
|
||||
saves you when it doesn't.
|
||||
- **`body { overflow-x: clip }`** as a backstop on every app (clip, not hidden — keeps
|
||||
sticky/anchored layouts working). A backstop, NOT a substitute for measuring.
|
||||
|
||||
**The check — run before calling ANY page done:** `document.documentElement.scrollWidth - document.documentElement.clientWidth` must equal `0`, tested at your dev width AND resized narrow (≤1024px, and a phone width ~390px). If > 0, find the offender:
|
||||
```js
|
||||
document.querySelectorAll('*').forEach(el=>{const r=el.getBoundingClientRect();
|
||||
if(r.right>innerWidth+1||r.left<-1) console.log(Math.round(r.right), el);});
|
||||
```
|
||||
|
||||
**Safety net:** `overflow-x: clip` on `body` (prefer `clip` over `hidden` — it clips without creating a scroll container, so it won't break `position:sticky`/anchored layouts). But a net is not a fix — **find and kill the root cause:**
|
||||
|
||||
- **`position:absolute` + `white-space:nowrap` anchored at an edge** (`left:100%`, `right:0`): a *centered* nowrap label on the right edge juts past the viewport. (Real ship, 2026-06: a progress bar's "300 · learned model" milestone label at `left:100%` with `translateX(-50%)` overflowed 23px → phantom horizontal scroll at sub-1180px widths.) **Anchor edge labels inward** — right end `right:0; transform:none`, left end `left:0; transform:none`.
|
||||
- **`100vw`** — includes the scrollbar width (~15px), so on any vertically-scrolling page it guarantees ~15px of horizontal overflow. Use `100%`.
|
||||
- **flex / grid children without `min-width:0`** — they refuse to shrink below their content and blow out the track (a long title in a flex card, a `<pre>` in a grid cell). Add `min-width:0`.
|
||||
- **long unbreakable strings** (URLs, hashes, tokens): `overflow-wrap:anywhere` or `word-break:break-word`.
|
||||
- fixed pixel widths wider than the viewport; large negative margins; oversized `position:absolute` elements.
|
||||
|
||||
The generalization: **anything pinned to an edge or sized in viewport units is a horizontal-overflow suspect — test narrow, measure `scrollWidth`, clip the body as backstop, and anchor edge-pinned content inward.**
|
||||
|
||||
## 5. Lay out in TASK order — minimize transition cost
|
||||
|
||||
Before placing elements, **walk the user's actual step sequence for completing the
|
||||
page's action**, then arrange elements in that same perceptual/view order. The
|
||||
layout should read like the task: orient → work → confirm. Any mouse travel or
|
||||
scrolling that serves no practical purpose is a defect.
|
||||
|
||||
- **Orient at top:** controls/options up top are good — they tell the user what
|
||||
the page is for and what it can do before they commit to reading it.
|
||||
- **Confirm where the work ENDS:** if the task is "review a long list, then act"
|
||||
(approve, flag, submit, save), the action buttons must ALSO exist at the
|
||||
bottom — where the user's eyes and cursor are when they finish. The original
|
||||
failure: a delete-review page with confirm buttons only in the top toolbar —
|
||||
after scrolling through 120 images, the user had to scroll all the way back up
|
||||
to click "flag the rest." Duplicate the action bar at the bottom (or make the
|
||||
toolbar sticky); both are one line of code, the scroll-back is paid per page.
|
||||
- **The heuristic: save the user transit time.** Every interaction has a path:
|
||||
where the eyes/cursor are when a step ends vs where the next step's control
|
||||
is. Sum those distances; shrink the big ones. Fitts's law for the page as a
|
||||
whole, not just one button.
|
||||
- **Check it in the render-and-critique loop (§1):** ask the judge "trace the
|
||||
task: where is the user when they finish each step, and how far is the next
|
||||
control?" — a layout can be aligned, balanced, and still force a round trip.
|
||||
|
||||
## 6. Balance is measurable — don't eyeball it (or trust a VLM's eye)
|
||||
|
||||
§1 says render and have fresh eyes critique it. That qualitative pass catches
|
||||
collisions and ragged alignment, but **a model has no reliable sense of visual
|
||||
balance** — ask a VLM "is this centered / balanced?" and it confabulates a verdict.
|
||||
The fix is to stop asking opinions and **measure a number**, then keep that number
|
||||
honest with an *independent* check. Use both: §1's fresh-eyes critique AND the hard
|
||||
number below. (This pairs a live in-browser box model + auto-balancer with an
|
||||
offline pixel-oracle that re-measures the rendered screenshot — see the
|
||||
`layout-audit.js` companion script in this skill.)
|
||||
|
||||
**The principle.** Visual balance is the *center of mass of visual weight*. It's
|
||||
arithmetic, not taste — so compute it.
|
||||
|
||||
**Optical center, not geometric.** Target `x = 0.50`, `y ≈ 0.46` — slightly high,
|
||||
because a centroid at literal 50% reads as sagging.
|
||||
|
||||
**Visual weight = area × ink-density, not area alone.** Same-size ≠ same-weight: a
|
||||
solid-black heading is heavy; a grey/ASCII/light image reads far lighter than its
|
||||
area; body text is sparse. Calibrated starting multipliers (from `asym.html`, re-tune
|
||||
per project — these were hand-guesses until corrected against the pixel oracle):
|
||||
```js
|
||||
const DENS = {portrait:0.34, h1:0.82, kicker:0.42, lead:0.22, body:0.16, meta:0.5};
|
||||
```
|
||||
|
||||
**Centroid.** Per axis, `centroid = Σ(wᵢ·posᵢ) / Σwᵢ`; balanced ⇔ the centroid sits
|
||||
on the optical center. To FIX imbalance, think see-saw: what counts is the **moment**
|
||||
= weight × distance-from-axis, so a heavy element near the edge is counterweighted by
|
||||
(a) an opposing weight, (b) a bigger element on the other side, (c) pulling the heavy
|
||||
element inward (shorter lever arm), or (d) shrinking it. That's exactly the
|
||||
auto-balancer's escalation order in `asym.html` — grow the opposing heading first
|
||||
(cheapest), then add weight, then pull the heavy element in, then shrink it (last
|
||||
resort).
|
||||
|
||||
**Two models — and why you need the independent one:**
|
||||
- **Cheap box model** (live tuning): put each element's weight at its bounding-box
|
||||
*center*. Instant, fine for dragging sliders. BUT it has a systematic bug —
|
||||
left-aligned text's ink sits *left* of its box, so the box model misplaces the
|
||||
weight. A metric that shares the layout's own assumptions is **circular**; it once
|
||||
reported "balanced" at a pixel-measured 0.93 lopsided.
|
||||
- **Ground-truth pixel oracle** (`analyze.py`): rasterize the *rendered* page
|
||||
(Playwright screenshot or html2canvas) and take the centroid of actual non-paper
|
||||
pixels, weighting each pixel by its distance from the background color. It knows
|
||||
nothing about the layout's intent — it just counts ink. **When the box model and
|
||||
the pixels disagree, the pixels win.** (`asym.html` closes the loop: it regresses
|
||||
the box-vs-pixel discrepancy and offers a trust dial α to blend toward the oracle.)
|
||||
- **Acceptance criterion (measurable):** `|centroid_x − 0.50| < 0.03` and
|
||||
`|centroid_y − 0.46| < 0.04`, plus low left/right and top/bottom imbalance
|
||||
(`|w_left − w_right| / total`).
|
||||
|
||||
**The verification gate (lighter than §4's, same spirit).** Before calling a
|
||||
balance-critical layout "balanced", do NOT assert it from the code or a VLM opinion —
|
||||
screenshot the *rendered* page, compute the ink-centroid offset from optical center,
|
||||
and report the actual number. This is the design-skill application of
|
||||
`verify-outputs-rule`: look at the real artifact, and make the validating check
|
||||
(pixels) independent of the thing you tuned (the layout). It's the quantitative
|
||||
complement to §1's qualitative critique.
|
||||
|
||||
## 7. The layout audit — metrics that MEDIATE the eye, never replace it
|
||||
|
||||
§6 covers balance; this generalizes it to a full deterministic sweep, and fixes the
|
||||
failure mode that matters most: **the model reads a metric/JSON and never looks at the
|
||||
screenshot, so it can't apply the common sense that catches the metric being wrong.**
|
||||
|
||||
`scripts/layout-audit.js` is a dependency-free pass you run via Playwright MCP
|
||||
`browser_evaluate` on a rendered page. It measures six things deterministically — all
|
||||
geometry, color, and pixels, no "does this look right?":
|
||||
|
||||
| check | how (deterministic) | tier |
|
||||
|---|---|---|
|
||||
| **collision** | content-rect intersection ≥12% | gate |
|
||||
| **contrast** | WCAG luminance ratio of text vs effective bg (<4.5, large <3) | gate |
|
||||
| **tap** | interactive targets <44×44 (Apple HIG) | gate |
|
||||
| **overflow** | `scrollWidth − clientWidth` (the §4 gate) | gate |
|
||||
| **alignment** | left-edge clusters → near-misses 1–7px off the shared line | signal |
|
||||
| **spacing** | gap CoV among a container's children | signal |
|
||||
| **balance** | ink-density-weighted centroid vs optical center (§6) | signal |
|
||||
|
||||
**What makes it mediate rather than replace:** it doesn't just return JSON — it **draws
|
||||
every finding as an SVG overlay onto the page**, so the *next* `browser_take_screenshot`
|
||||
is an **annotated screenshot**. The number tells you WHERE to look; you then look and
|
||||
decide. This is mandatory, not optional:
|
||||
|
||||
```
|
||||
browser_evaluate({ function: "() => { <paste scripts/layout-audit.js> ; return __audit({}); }" })
|
||||
browser_take_screenshot() // ← the overlay is now on the page. VIEW IT. Reason over it.
|
||||
```
|
||||
|
||||
Pass `{align:'.card .title,.card .price', space:'.feature-list'}` to scope the two
|
||||
selector-dependent checks; pass `{contentSelector:'…'}` for non-semantic layouts where
|
||||
collision needs help finding the blocks.
|
||||
|
||||
**These are HEURISTICS, not laws — and they split into two kinds you must not conflate:**
|
||||
|
||||
- **GATES = correctness** (overflow, contrast, tap). These measure accessibility/
|
||||
usability *facts*, not taste. Failing one is a real defect. Safe to **block** on.
|
||||
(Collision is a near-gate: usually a real bug, but can be intentional — so eye-confirm,
|
||||
don't auto-fail.)
|
||||
- **SIGNALS = convention** (balance, alignment, spacing rhythm). These measure how
|
||||
closely the layout matches a *symmetric, regular, gridded* aesthetic — which is exactly
|
||||
the **generic mean** §2 tells you to push *away* from. **Optimizing a layout to maximize
|
||||
these scores makes it blander.** An off-center balance, a deliberate misalignment, an
|
||||
uneven rhythm are core creative tools and frequently the best thing on the page. Treat
|
||||
signals as "worth a look," **never** as defects to fix.
|
||||
|
||||
**The discipline (the whole point — bias hard toward this):**
|
||||
- **Never accept a metric you have not looked at.** A flag is a *pointer to look*, not a
|
||||
verdict. Reading `collisions: 1` and acting without viewing the annotated shot is the
|
||||
exact failure this section exists to kill.
|
||||
- **Use signals to catch ACCIDENTS, never to enforce convention.** A 7px alignment drift
|
||||
you didn't mean, a phantom scrollbar, a 1.9:1 caption — catch those. But the *same*
|
||||
balance/alignment/spacing signal fires on deliberate asymmetry, intentional overlap,
|
||||
and expressive rhythm. **When the metric and the interesting choice conflict, the
|
||||
interesting choice usually wins.** Do not "fix" a signal toward symmetry/evenness unless
|
||||
the eye judges the deviation actually worse. A model that maximizes these scores designs
|
||||
the mean.
|
||||
- **Overrule flags the eye judges intentional.** Brutalist headline overlap, avatar on a
|
||||
banner, asymmetric hero — the metric flags them; common sense overrules. Proven live in
|
||||
the worked example: obeying the collision check on the brutalist mock removes the overlap
|
||||
and the design goes *flat*.
|
||||
- **Gates are necessary, not sufficient.** `gates_pass:true` (overflow/contrast/tap all 0)
|
||||
clears the deterministic floor — it does **not** mean the layout is good. A bland centered
|
||||
template passes every gate and is still the mean. After gates pass, the real judgment (§1
|
||||
fresh-eyes critique, taste, brand fit) still has to happen.
|
||||
|
||||
**The proof, made concrete:** build a page that runs all six algorithms live on a few
|
||||
realistic mock sites in different styles, each with a toggle between the layout **as
|
||||
designed** and the version **obeying the metric**, plus on-render overlays. It shows both
|
||||
halves: obeying a *gate* fixes a real bug (low-contrast CTA, sub-44 tap target, overlapping
|
||||
cards), while obeying a *signal* makes it worse (an asymmetric editorial hero is the more
|
||||
interesting layout; "correcting" a deliberate brutalist overlap flattens it). That explorable
|
||||
is where `layout-audit.js` was distilled from.
|
||||
|
||||
## 8. Optical craft — perception beats geometry
|
||||
|
||||
The audit's alignment check (§7) measures *geometric* edges. The eye doesn't read geometry,
|
||||
it reads perception — so a few cases need a manual nudge the metric can't make. These are
|
||||
eye-judgments, not gates. (From the Web Interface Guidelines, `vercel-labs/web-interface-guidelines` @ `4e799d4`.)
|
||||
|
||||
- **Optical alignment — nudge ±1–2px when it *looks* off though it measures centered.** A
|
||||
play-triangle in a round button must shift right of geometric center to look centered (its
|
||||
visual mass is left-biased). Glyphs, arrows, and asymmetric icons often need the same. Text
|
||||
vertically centered by box metrics frequently sits a hair low — lift it. Geometry is the
|
||||
starting point; the eye is the judge.
|
||||
- **Balance icon/text lockups.** When an icon sits beside text, match their *visual weight* —
|
||||
adjust the icon's stroke, size, spacing, or color so neither overpowers. A thin-stroke icon
|
||||
next to medium-weight text looks weak; thicken its stroke (or size it up slightly) so they
|
||||
read as one lockup. Optical size, not equal pixel size, is the target.
|
||||
- This is the same principle as the §6/§7 debias: the number gets you close; the eye makes the
|
||||
final 1px call. Don't let a geometric alignment metric *prevent* an optical correction.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Use this skill only when the task clearly matches its upstream source and local project context.
|
||||
- Verify commands, generated code, dependencies, credentials, and external service behavior before applying changes.
|
||||
- Do not treat examples as a substitute for environment-specific tests, security review, or user approval for destructive or costly actions.
|
||||
@@ -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