# 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

Cheap enough to run on every change, and each one catches a defect a typecheck cannot see. 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.

**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.

**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` — the floor that says a column
may give way (`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. A register's own boxes, RENDERED** — the half a stylesheet cannot show you. Every band
and every press box the register draws is at least `--lotics-register-row` (`constitution.md` §2's
floor): a head declaring the band still rendered a 32px control inside it, which is 4px of dead
edge on every column heading. And nothing in a register's scroll port reaches past its ROWS —
compare `scrollWidth` to `clientWidth`, then find what is outside: a hover pill bleeding 8px
turns a register that fits into one with a scrollbar over eight pixels of nothing.

**8k. Opinions the reader cannot operate** — authored bands nobody asked for, and pills crowding a
search. The source-side signature is cheaper than either measurement. The fix is not to shrink them — it is to cut
them.

**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 the reader never sees, and counting them produces confident nonsense.

### 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` on one rule is the other finding — though asymmetry
is not automatically wrong: a rule that BELONGS to the element above it sits tight under it, and
that gap is what says which side owns it. The finding is asymmetry nobody chose.

### 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, so read that beside it: a clipped element carrying neither is one whose
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) => [...document.querySelector(sel).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) };
})
```

Ask which children BELONG together. If the answer is not "all of them", one gap is serving several
relationships.

### 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 not the one you selected: a press target is
  // frequently transparent with its ground on a child, so reading the target's
  // own backgroundColor reports a perfectly good hover as having none.
  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**, and it does not look
like a bug in a screenshot — the thing renders, presses and announces correctly, and simply 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 has three gotchas of its own → [testing.md](./testing.md).

---

## What measurement cannot see

Most of what makes a screen good, which is why the probes are a floor and not a target. They will
not tell you the density is wrong for the job, that the register has no point of view, or that
nothing on the page belongs to this product rather than to any product. 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.
