# Reviewing a screen you built

The other docs say what good looks like. This one says how to find out whether you achieved it: a
typecheck, a lint and a test are blind to how a screen reads.

**The discipline is render it, measure it, and let the numbers say whether the rules held.**
"Looks off" is where a finding starts — trust the impression and use measurement to say precisely
WHAT is off. When the probes come back clean and the screen is still wrong, the probes are not the
answer: the form, the density, or the fact that nothing on screen belongs to this product is.

1. **Render it**, and drive it with browser automation that can evaluate JS in the page.
2. **Look at it first.** Form a view before running anything and write it down; that view is what
   the probes are being asked to explain.
3. **Run the gates**, then reach for whichever probes speak to what you saw. They are a diagnostic
   kit, not a checklist.
4. **Fix, then re-check** the way you found it.
5. **Screenshot.** Some defects live only in the picture — a mark floating over a tile that never
   loaded measures perfectly and is obviously broken on sight.

**Map raw CSS back to the ladder as you go.** A finding reads as `xs/500/muted`, not
`12px/500/rgb(102,102,111)`. The same rung reports different pixels above and below 768px, resolved
against the width you are measuring.

**Measure at 1280 and 375.** Treatment bugs are width-independent on a screen that merely REFLOWS,
but a screen that FORKS is a second screen running code the other width never executes. **Read the
width the component actually RECEIVES, never the viewport you set.**

## Before you commit — six checks

The numbers are [constitution.md](./constitution.md)'s.

1. **One value, one rendering.** A chart draws the entity its table draws, and a value's filter
   renders it the way its cell does.
2. **Every figure is formatted, by the reader's locale** — money, dates, quantities. A number
   joined to a string is a number in somebody else's convention.
3. **One accent.** Everything else is neutral or a valence, whose colour IS the meaning.
4. **Every gap is a rung and the ratio is 1 : 3 : 6**: 4 inside a pair, 8 inside a group, 24
   between bands, 48 between sections.
5. **No bare native control.** A `<button>`, `<input>`, `<select>` or `<table>` in your source is a
   control outside the type ramp, the locale and the keyboard contract.
6. **No raw id reaches the reader.** `rec_`, `opt_` and `fld_` are keys; a cell shows the name.

## Three gates, before any probe

**0. RIGHT FORM.** Name what the subject IS before composing anything. Every probe below reads a
screen that is already the right shape; none of them can tell you it should have been a rail.

**1. SUBTRACT.** Remove everything the reader cannot act on and everything that restates something
already on screen. What is left is the screen.

**2. RIGHT CONTROL.** Each value's control follows its data type, and each act's control follows
what pressing it does.

## The probes

**1. Type inventory.** Collect every run of language with its rung, weight and ink; count DISTINCT
rungs and name the role each carries. Weight here is a font FAMILY, not an axis. Same shape → same
treatment. A combination with one member is a singleton and usually a slip. Measure a stacked pair
against the type inside it, and a shrunk composite against its full-size sibling. A PAIR is a line
under its subject and starting where it starts — a row that WRAPPED is two objects, whatever sits
under what.

**2. Ground, border and radius across siblings.** A singleton ground is a finding. Tally
chromatic grounds — the budget is ONE. A control riding a hoverable surface must be DARKER than it.
A pressable that does not NAVIGATE must not wear navigation ink. A control drawing NOTHING at rest
is text. A semantic token passed where a raw value is expected fails SILENTLY and typechecks.

**3. Rules — ask what each one SEPARATES.** A rule between like and like is noise; the boundary
that needs the reader's eye is the one that should be widest.

**4. Gaps — measure RATIOS, not sizes.** The rhythm is 1 : 3 : 6 (`constitution.md` §2); a screen
whose within-group and between-group gaps are the same number reads as blended whatever the numbers
are. **Compare a container's gap with the largest gap INSIDE each child**, the kit's own included —
the pair that ships wrong is your number against one a component wrote.

**5. State diff.** Snapshot at rest, hovered, focused and active, then diff. A themeable control
must derive every state from its token. **A prop's EFFECT must match its claim**, and a prop with
NO effect is invisible to that diff — so measure a component against ITSELF under each optional
prop.

**5a. Dead props — SWEEP the whole list, never one.** A name in the props interface and the
destructure and nowhere else in the render is documented and drawn nowhere, and one is a habit
across its component. Three look dead and are not: a rest spread passing it on, a renamed
destructure, a fallback read through `??`.

**6. Alignment and drift.** One left edge — the whole page, then the whole APP. A number you can
EDIT and a number you can only READ drift apart on a money column. A derived result must END the
expression it derives from. A DISABLED control that still wears its affordance. A CENTRED child
hides its own drift. A VARIABLE-width leading slot destroys the column behind it. For every numeric
column: is its unit named anywhere, and is it ONE unit? A slot holding another component's part is
sized by that component's constant. Labels along a track: compare each one's HEIGHT across the set.
A row beat belongs to the PAGE. Measure a bar's TRACK, not its card. Cards side by side: collect
their BOTTOM edges, not their tops. A scroller's CONTENT against the scroller — measure both, never
just the frame.

**7. Surface walk — open it, press it, read it.** Open what the row opens and name the record
before you read it. Press every control. TAB through it, then diff what a keyboard reaches against
what LOOKS pressable; inside an open overlay, let the focus guard land first. Diff a promoted field
against where it came from. Read every string in its SETTLED state. Every state designed. A comment
stating a PRIORITY between two regions is a claim to VERIFY. Read the document OUTLINE.

**7a. A chart's LINKING marks, against the marks they link.** A connector sized by the GAP between
bars disappears as the bars get wider; the signature is a chart that measures perfectly and cannot
be read. Compare orientations before blaming the data.

**8. Data probe — count what is really there.** Query the table before choosing a surface, and read
the stored value before blaming the renderer; the obvious column may be the empty one, and a
fixture can fail in the generous direction too. Grep rendered text for internal identifiers — first
probe, every audit. For every value, name its AUTHOR, then check that author can change it. Ask of
every value: could this exist in the data?

**8a. Navigation depth — count the NAVIGATION strips on one screen.** More than one is a filter
wearing navigation's clothes; a `sequence` rail is not in that count.
Walk every tab before concluding; the cheaper signature, before a browser, is grepping the tab
LABELS for "and".

**8b. Does each tab earn its slot — count its rows AS A SCOPED READER**, then ask who owns the
data. A tab that looks like a list while you develop can be a single row in the field.

**8c. First paint — count the controls the task actually needs.** On a screen whose job is ENTRY,
count `input`/`select`/`textarea` on first paint; the usual gate is a mode chooser nobody asked
for. The same count catches the inverse — a screen that asks for one field and hides the rest.

**8d. Dead counts — every summary number that names a set you cannot reach.** Press the filter and
count rows, because a rendered chip proves nothing.

**8e. Derivable columns — every column another cell already answers.** Keep it only when the badge
carries a REASON rather than a state.

**8f. Does the summary describe WHAT'S SHOWN — press a facet, then re-read it.** A strip that keeps
describing the unfiltered set survives every other check. A count the facet already states is a
different defect on the same strip.

**8g. Truncation — every string the layout cut, at the width a phone actually is.** Read the result
by WHAT was cut, not how much: a row whose halves both measure fine and whose text is cut is still
a defect.

**8g-bis. A text leaf rendered at width ZERO**, at 390: every element with a text child of its own
whose box is under 1px wide and is not zero-height. `min-width: 0` (`constitution.md` §6) lets
that column absorb a rigid row's whole deficit, so 8g
sees no cut string because there is no string left to cut. Fix it at the ROW: stack below the
threshold its container query names, or make the rigid sibling give way.

**8h. Which columns SURVIVE the narrow width**, and what the FIRST survivor is — position outranks
every rank. Count each survivor as a FACT, not a column.

**8h-bis. A cell that grows a second line lifts its first**, so a column whose cells are sometimes
one line and sometimes two has no baseline.

**8i. Row controls against a multi-line cell** — the trailing control drops by a data-dependent
amount unless the register aligns to the top.

**8j. Does every cell sit under the header that names it?** A row with fewer cells than the table
declares columns; a box that does not match its header's; ink starting N px right of its header, by
the same N on every row.

**8j-bis. The boxes a surface RENDERS** — the half a stylesheet cannot show you; three probes read it.
`probe:registers`: every band and press box stands the band floor, nothing in the scroll port
reaches past its ROWS, and nothing a cell draws leaves the cell.
`probe:rows`: every gap between two parts of a row is a rung or nothing, a NAMED gap is one number
on every row-shaped surface, a statement or folded reading is whole or absent, and the next row
stands further off than any two tracks inside this one.
`probe:briefs`: a gallery record claiming the fold ends its brief inside the first viewport.

**8k. Opinions the reader cannot operate** — authored bands nobody asked for, and pills crowding a
search. The fix is not to shrink them — it is to cut them.

**8l. A slot reserved for what is mostly not there** — a column 40px or wider blank on more than
half the rows in view, or a row-act gutter leaving 40px beside their acts; a ghost row
(`data-ghost`) is not counted.

**8m. A column shed beside room** — folded or dropped while the slots, or gaps wider than the set
one, hold its width and a gap beyond what each must seat: the fit gave up room it had.

**8n. A section's add off its heading row** — a `data-create` drawn in the body: its one place is
the heading row's trailing slot, `SectionHeadingAdd`.

**8o. A count of the page, not the set** — a tab, "all" or "1 of N" figure that disagrees with the
server's count of the same read.

**9. The absence pass — what catches "bland".** Type range, chromatic count, identity marks. Then:
what does the screen DECIDE? What is inherently visual in THIS data? What makes this belong to THIS
product? (A driver's `.focus()` does not set `:focus-visible`, so check that state by keyboard.)

**9c. What the screen does when the data MOVES — the cross-frame check.** The one defect that
exists in no single frame: what the screen destroys when a row arrives.

**9d. A SEMANTIC section drawn as an ordinary one** — a `DangerSection`, an empty state, a completion
— and the primitive nobody reached for.

**10. Microcopy.** No punctuation doing a word's job. One job per element. Structure must encode
something true.

---

## Extraction snippets

Run each at 1280 and 375. The tables ARE the findings.

### Type inventory — every run of language, in document order

Walks TEXT NODES rather than elements, so a run-in label and its sentence are separate rows instead
of one blended string.

```js
() => {
  const SZ = { '12px':'xs','14px':'sm','16px':'md','18px':'lg','20px':'lg','22px':'xl','24px':'xl','28px':'xxl','32px':'xxl/xxxl','48px':'xxxl' };
  // An unnamed ink reports as its triple, which is the finding. Keyed on 8-bit because `color-mix`
  // resolves to `color(srgb …)` floats: normalise, never add a second literal.
  const INK = { '24,24,27':'default','102,102,111':'muted','161,161,170':'inactive','255,255,255':'onInverse','113,113,122':'zinc-500 RETIRED','63,63,70':'zinc-700 RETIRED' };
  const ink = (c, v = (c.match(/[\d.]+/g) || []).slice(0, 3)) => {
    if (v.length < 3) return c;
    const k = v.map((n) => Math.round(c.startsWith('color(') ? n * 255 : n)).join(',');
    return INK[k] || k;
  };
  const out = [];
  const walk = (n) => {
    if (n.nodeType === 3 && n.textContent.trim()) {
      const cs = getComputedStyle(n.parentElement);
      const rg = document.createRange(); rg.selectNode(n); const r = rg.getBoundingClientRect();
      if (r.height) out.push({
        t: n.textContent.trim().slice(0, 34),
        y: Math.round(r.y), x: Math.round(r.x),
        rung: SZ[cs.fontSize] || cs.fontSize, w: cs.fontWeight,
        ink: ink(cs.color),
      });
    }
    n.childNodes.forEach(walk);
  };
  walk(document.body);
  return out.sort((a, b) => a.y - b.y);
}
```

Then **count the treatments**: collapse to `rung/weight/ink` and tally. Two combinations doing the
same job is the inconsistency you were sent to find. **Skip `SCRIPT`/`STYLE`/`TITLE`/`META`/`LINK`/
`NOSCRIPT` and check `checkVisibility()`** in any variant that walks ELEMENTS — those tags hold text
nodes nobody sees.

### Identity marks — N identical is a finding, and only a per-COLUMN count sees it

Group by the x band, because that is what separates the row's own subject mark from a supporting
column that borrowed identity weight.

```js
// → [{ x, count, distinct, sample }] — flag any band with count > 1 && distinct === 1
Object.values(
  [...document.querySelectorAll('[data-avatar], [class*="avatar"], img')]
    .filter((n) => n.getBoundingClientRect().width > 0)
    .reduce((acc, n) => {
      const band = Math.round(n.getBoundingClientRect().x / 24) * 24;   // column, not pixel
      const cs = getComputedStyle(n);
      const key = [cs.backgroundColor, n.textContent?.trim(), n.getAttribute("src") ?? ""].join("|");
      acc[band] ??= { x: band, keys: new Set(), count: 0 };
      acc[band].keys.add(key);
      acc[band].count++;
      return acc;
    }, {}),
).map((b) => ({ x: b.x, count: b.count, distinct: b.keys.size, sample: [...b.keys][0] }));
```

A band whose `distinct` is well under `count` is the softer version — a palette collapsing on real
names.

### Separator beats — is the biggest boundary the widest?

**A rule is a painted LINE, and it is drawn two ways.** Selecting "thin and wide" alone counts a
transparent flex spacer and misses a border on a box of ordinary height.

```js
() => {
  const CLEAR = v => !v || v === 'rgba(0, 0, 0, 0)' || v === 'transparent';
  const lineOf = e => {
    const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
    if (!(r.width > 200 && e.checkVisibility?.())) return null;
    if (r.height <= 1.5) {                       // a hairline BOX
      if (!CLEAR(cs.backgroundColor)) return { y: r.y, paint: cs.backgroundColor };
      for (const s of ['Top', 'Bottom'])         // …or a hairline drawn as a border
        if (parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`]))
          return { y: r.y, paint: cs[`border${s}Color`] };
      return null;                               // painted nothing: a spacer, not a rule
    }
    const sides = ['Top', 'Bottom'].filter(s =>
      parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`]));
    const boxed = ['Left', 'Right'].every(s =>
      parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`])) && sides.length === 2;
    if (boxed || sides.length !== 1) return null; // a control's own edge is not a rule
    // The LINE's y is the border's edge, never the box's origin.
    return { y: sides[0] === 'Top' ? r.y : r.bottom, paint: cs[`border${sides[0]}Color`] };
  };
  const rules = [...document.querySelectorAll('div,hr')]
    .map(lineOf).filter(Boolean)
    .map(l => ({ y: Math.round(l.y), paint: l.paint }))
    .sort((a, b) => a.y - b.y);
  const blocks = [...document.querySelectorAll('*')]
    .filter(e => e.children.length === 0 && e.textContent.trim())
    .map(e => { const r = e.getBoundingClientRect(); return { y: Math.round(r.y), b: Math.round(r.bottom), t: e.textContent.trim().slice(0, 24) }; });
  return rules.map(({ y, paint }) => ({
    rule: y, paint,
    above: y - Math.max(...blocks.filter(o => o.b <= y + 1).map(o => o.b), -Infinity),
    below: Math.min(...blocks.filter(o => o.y >= y - 1).map(o => o.y), Infinity) - y,
    separates: [blocks.filter(o => o.b <= y + 1).sort((a, c) => c.b - a.b)[0]?.t,
                blocks.filter(o => o.y >= y - 1).sort((a, c) => a.y - c.y)[0]?.t].join(' | '),
  }));
}
```

Read `separates` first. Unequal `above`/`below` is a finding only where nobody chose it: a rule
tight under the element above BELONGS to it.

### Clipping, overflow, collision

```js
() => ({
  pageOverflowsX: document.documentElement.scrollWidth > window.innerWidth,
  clipped: [...document.querySelectorAll('*')]
    .filter(e => e.scrollWidth > e.clientWidth + 1 && getComputedStyle(e).overflow !== 'visible')
    .map(e => ({ t: e.textContent.trim().slice(0, 40), w: e.clientWidth, needs: e.scrollWidth,
                 ellipsis: getComputedStyle(e).textOverflow })),
})
```

`ellipsis: "clip"` means the text ends mid-word with nothing marking the cut. A clamped run reports
`webkitLineClamp` instead; an element carrying neither has an overflow nothing marks.

### Measure INK, not the box — a stretched label hides the gap it sits in

```js
(sel) => { const rg = document.createRange(); rg.selectNodeContents(document.querySelector(sel)); return rg.getBoundingClientRect(); }
```

A label inside a `flex: 1` slot measures as wide as the slot, so a box-to-box gap reads as the
container's `gap` while the reader sees hundreds of pixels of void. Any gap RATIO taken across a
stretching text is wrong in the reassuring direction.

### Grouping — is one `gap` flattening the hierarchy?

```js
(sel) => { const el = document.querySelector(sel), g = (n) => parseFloat(getComputedStyle(n).rowGap) || 0;
  return [...el.children].map(c => { const r = c.getBoundingClientRect();
    return { t: c.textContent.trim().slice(0, 40), y: Math.round(r.y), h: Math.round(r.height),
             gap: g(el), inside: Math.max(0, ...[c, ...c.querySelectorAll('*')].map(g)) }; }); }
```

Ask which children BELONG together. If the answer is not "all of them", one gap is serving several
relationships — and `inside >= gap` is a boundary tighter than one within a child.

### Interaction states — does the control move when you touch it?

Snapshot the SAME selector four times — at rest, hovered, focused, and while editing — then diff.

```js
(sel) => [...document.querySelectorAll(sel)].map(e => {
  const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
  const inner = e.querySelector('input, textarea, [contenteditable]') || e;
  const ir = inner.getBoundingClientRect();
  // The PAINTED node is often a child of a transparent press target.
  const opaque = (x) => { const b = getComputedStyle(x).backgroundColor; return b && b !== 'rgba(0, 0, 0, 0)' ? b : null; };
  const painted = opaque(e) || [...e.querySelectorAll('*')].map(opaque).find(Boolean) || 'none';
  return {
    box: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
    text: [Math.round(ir.x), Math.round(ir.y)],
    border: `${cs.borderTopWidth} ${cs.borderTopColor}`,
    bg: painted,
    shadow: cs.boxShadow,
  };
})
```

`border`/`bg`/`shadow` differing is the design; `box`/`text` differing is the defect.

**A control that differs in NOTHING is the finding this catches most often** — it renders, presses
and announces, and never says it can be pressed. What should change is the surface's OWN signature one rung deeper, never a
different colour and never the cursor. And a surface with no press handler must NOT light up: an
affordance on something inert advertises an action that does not exist.

### Reachability — what the eye calls a control against what the tab order holds

```js
() => {
  const root = document.querySelector('[role="dialog"]') ?? document.body;
  const operable = [...root.querySelectorAll(
    'a[href], button, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"]), input, select, textarea',
  )];
  const looksPressable = [...root.querySelectorAll("*")].filter((e) => {
    if (e.children.length || !(e.textContent || "").trim()) return false;
    const cs = getComputedStyle(e);
    return cs.textDecorationLine.includes("underline") || cs.cursor === "pointer";
  });
  const unreachable = looksPressable.filter((e) => !e.closest(
    'a[href], button, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])',
  ));
  return {
    operable: operable.length,
    unreachable: unreachable.map((e) => (e.textContent || "").trim().slice(0, 40)),
  };
}
```

`unreachable` must come back empty. The count beside it is the other half of the reading: a whole
pane offering two or three tab stops is usually a screen whose verbs are all prose. Driving the
kit's own anatomies → [testing.md](./testing.md).

---

## What measurement cannot see

Most of what makes a screen good: the probes are a floor, not a target. Nor will they show a
disclosure you did not expand, copy that only lies in its terminal state (a settled row still
saying "Loading…" measures perfectly), or a flattering fixture — compare the preview data's SHAPE
against what the real source emits before trusting any of it.

## When you find something

- **Fix the CLASS, not the instance — and grep by the JOB, not the styling.** Name the job in one
  phrase ("names a column", "commits the row", "opens a peek") and search for everything else doing
  it. Fixing the tab in front of you is what leaves the same field rendering two ways on two tabs.
- **Three fixes in one spot means the ARRANGEMENT is the defect.** Count repairs, not their
  difficulty; the third is the signal they were never independent.
- **Prefer changing the SHAPE over changing the weight.** Restructuring states a difference;
  nudging a weight whispers it, and a whisper reads as a slip.
- **When the kit is the offender, fix the kit.** An app-local override is a fork, and a component
  built beside one that nearly fits is a fork that drifts.
- **Never resolve a finding with "the docs say X."** Resolve it with what makes the screen better,
  then push the answer back into the docs. Where the docs ARE load-bearing is IDENTITY: the fonts,
  the type ramp, the icon set, one accent per screen.
