| name | visual-review |
| description | Judge and dial in the VISUAL correctness of a UI change — vertical alignment of icons beside text, optical centering, spacing balance, occlusion and clipping — by MEASURING glyph ink in the running browser and by critically reading your own screenshots. Invoke (via the Skill tool) before declaring ANY new or changed UI correct, and specifically whenever you place an icon, glyph, emoji, badge, chip, or counter next to text. Pairs with `adhoc-cdp`, which covers how to boot the stack and take the shot; this skill covers how to JUDGE what the shot shows and how to fix what is off. Carries the ink-measurement routine, the per-glyph offsets it produced, and the instrument bug that makes a naive baseline probe report ~3x the real error. |
| metadata | {"internal":true} |
Visual review — measure the ink, then criticize your own screenshot
Two failures ship bad UI, and this skill exists because both happened repeatedly on this repo:
- Trusting the box model.
items-center, "padding is equal", getBoundingClientRect all agree —
and the thing still looks broken, because none of them describe where the eye reads a mark.
- Taking a screenshot and not looking at it. Capturing evidence is not reviewing evidence. Posting
a shot the maintainer then has to tell you is ugly means you did the expensive half and skipped the
cheap half.
Codified 2026-07-29, after one badge took three maintainer-prompted rounds to get right: the treatment
was invented where the real product should have been measured, and then the glyphs were left riding
~1.2px low because items-center was trusted to align them. The capability was never the problem —
doing it unprompted is.
Rule 0 — you are the first reviewer of your own screenshot
Before any UI change leaves your hands, take the shot, read it back, and try to find what is wrong
with it. Not "does it render" — is it good? Ask, in order:
- Is every icon optically centered against the text beside it, or does one ride high/low?
- Do glyphs sitting in one cluster carry comparable visual weight, or is one obviously heavier?
- Does anything collide, clip, truncate oddly, or sit a pixel off a neighbour it should align with?
- At a NARROW width too: does it still hold, or does something overlap or escape its row?
If you would not ship it to a design-conscious colleague without a caveat, it is not done. Write the
caveat down and fix it instead. Never hand over a screenshot you have not personally critiqued.
A useful forcing function: capture at a viewport narrow enough that the component fills the frame
(--w=780 for a 720px modal), so the detail is actually large enough to judge. A component that is
40px tall inside a 1400px shot cannot be reviewed, and glancing at it counts for nothing.
Rule 1 — icon-beside-text alignment is an INK problem, and every glyph differs
items-center centers a glyph's box on the flex line. The eye aligns ink. These are never the
same thing:
- A digit has no descender, so its ink rides HIGH inside the line box.
- An SVG's ink sits wherever its path falls inside its viewBox — an octicon's ink is not centered
in its 16-unit box, and a lucide stroke glyph's ink is smaller again.
- An emoji is a bitmap with its own metrics that ignore your font size entirely.
So centering the boxes leaves every glyph low or high by a different amount, and one shared nudge
cannot fix a cluster. Measure each glyph and correct each glyph.
Worked result from the GitHub picker's count badges (11.5px text, 12px icons):
| glyph | ink height | offset from the digit's ink centre | correction |
|---|
octicon-git-pull-request (filled) | 10.8px | 1.16px low | translateY(-0.1em) |
lucide MessageSquare (stroke) | 9.0px | 1.29px low | translateY(-0.112em) |
| 👍 emoji | 16.0px | 0.26px — sub-pixel | none; a nudge only blurs the bitmap |
Residual after correcting: 0.01px on both icons.
Two things that generalize:
- Express the correction in
em, not px, so it tracks the font size instead of pinning to
today's. Then PROVE it scales — double the cluster's font size and re-measure; the residual must
stay near zero. (It did: 0.03px / 0.01px at 23px/24px.)
- Leave sub-pixel offsets alone. Below ~0.3px you are under the device grid, and transforming a
bitmap emoji by a fraction of a pixel makes it blurrier, not straighter.
Rule 2 — the routine (copy this; it is the instrument)
Save as a file and pass it to shot.mjs with @:
node scripts/shot.mjs "$URL" out.png @/tmp/ink-measure.js --w=780 --wait=2000
(() => {
const baselineOfTextNode = (node) => {
const span = document.createElement("span")
node.parentNode.insertBefore(span, node)
span.appendChild(node)
const probe = document.createElement("span")
probe.style.cssText = "display:inline-block;width:0;height:0;padding:0;margin:0;border:0"
span.appendChild(probe)
const baseline = probe.getBoundingClientRect().bottom
const cs = getComputedStyle(span)
const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} / ${cs.lineHeight} ${cs.fontFamily}`
probe.remove()
span.parentNode.insertBefore(node, span)
span.remove()
return { baseline, font }
}
const inkOfText = (text, font, baseline) => {
const c = document.createElement("canvas").getContext("2d")
c.font = font
const m = c.measureText(text)
return { top: baseline - m.actualBoundingBoxAscent, bottom: baseline + m.actualBoundingBoxDescent }
}
const inkOfSvg = (svg) => {
const rects = [...svg.querySelectorAll("path,rect,circle,ellipse,polyline,polygon,line")].map((g) =>
g.getBoundingClientRect(),
)
return { top: Math.min(...rects.map((r) => r.top)), bottom: Math.max(...rects.map((r) => r.bottom)) }
}
const analyze = (holder, name) => {
const digitNode = [...holder.childNodes].reverse().find((n) => n.nodeType === 3 && /\S/.test(n.textContent))
const { baseline, font } = baselineOfTextNode(digitNode)
const textInk = inkOfText(digitNode.textContent.trim(), font, baseline)
const svg = holder.querySelector("svg")
let glyphInk
if (svg) {
glyphInk = inkOfSvg(svg)
} else {
const em = [...holder.childNodes].find((n) => n.nodeType === 1 && /\p{Extended_Pictographic}/u.test(n.textContent))
const emNode = [...em.childNodes].find((n) => n.nodeType === 3)
const eb = baselineOfTextNode(emNode)
glyphInk = inkOfText(emNode.textContent.trim(), eb.font, eb.baseline)
}
const mid = (o) => (o.top + o.bottom) / 2
return {
glyph: name,
glyphInkHeight: +(glyphInk.bottom - glyphInk.top).toFixed(2),
offsetPx: +(mid(textInk) - mid(glyphInk)).toFixed(2),
}
}
return [...document.querySelectorAll("SELECTOR")].map((el, i) => analyze(el, `glyph ${i}`))
})()
Sign convention, stated once because getting it backwards doubles the error: the routine returns
textInkCentre - glyphInkCentre. A NEGATIVE value means the glyph sits BELOW the text and must be
lifted (translateY negative). Re-run after correcting and confirm the residual is ~0 — never assume
your nudge landed.
Beside PROSE, align to the cap band — not to the string's own ink
inkOfText measures the actual bounding box of the specific string. That is exact for a digit
(uniform, no descender), which is what this routine was written for — and unstable for a sentence,
because the reference then moves with whatever letters happen to be in that row. Measured on a to-do
checklist (2026-07-29): the SAME glyph at the SAME size read 3.06px beside "…network grant…"
and 1.88px beside "…unconfined is fatal" — a 1.2px spread that is purely the descender. A per-row
nudge is meaningless, so swap the reference for the string-independent band the eye reads as "the line
of text", baseline → cap height:
const capBand = (font, baseline) => {
const c = document.createElement("canvas").getContext("2d")
c.font = font
return { top: baseline - c.measureText("H").actualBoundingBoxAscent, bottom: baseline }
}
Keep inkOfText alongside it and print both — the gap between them is the descender tell.
Three more ways this instrument lies, all found in one sitting and each producing a plausible number:
- A px-sized icon makes an
em correction a lie. <Icon size={12}/> pins the glyph while the text
around it scales, so the geometry the correction came from stops holding. Size the glyph 1em and the
scale check passes for the right reason.
1em resolves against the glyph's OWN inherited font-size. If the icon's parent is the card
(12.5px) while its text sibling is pinned text-[11.5px], you are aligning a 12.5px glyph to 11.5px
text and the correction encodes that accident. Put the font-size on the wrapper so the pair shares one.
- A wrapped row breaks the baseline probe. It reports the LAST line's baseline while the glyph
aligns to the first — which read as a 42px error. Force
white-space:nowrap for the measurement.
And when a residual only appears at a scaled size, re-measure RAW (correction neutralized, one pass per
page load) before theorizing: chasing an "instrument damages what it measures" story cost a cycle, and
the raw numbers showed the real thing — an ~11.5px box centred on a flex line is genuinely not
scale-invariant (11.5px wanted -0.137em, 23px and 46px both wanted -0.094em). No single em zeroes
both, so correct for the size that SHIPS and say so in the comment.
Rule 2b — geometry ink is BLIND to what a mark paints outside its box
Everything above measures ink from GEOMETRY — canvas text metrics for a glyph, the union of an SVG's
geometry children for an icon. Exact for those, and it cannot see a single pixel of box-shadow,
outline, glow or blur. So two marks with identical boxes, identical getBoundingClientRect, and
identical computed width can still read as different-sized marks, and no DOM measurement will
tell you which.
Measured 2026-07-30 on the queue card's liveness dots: the bright dot is a 6px circle plus a
box-shadow: 0 0 0 1px halo = 8.0px of painted ink; the quiet "alive but idle" dot was a bare 6px
circle = 6.0px. Every box reading said "both 6px, identical"; the maintainer said "I don't
understand why the blue dot is so small." The halo was a third of the mark's width.
When marks that should match still look mismatched and geometry says they agree, measure the PIXELS:
node scripts/ink-pixels.mjs "$URL" ".fray-live-dot, .fray-live-dot-quiet" --dsf=4
It screenshots each element with a padded clip, decodes it onto a canvas in-page, and scans for pixels
that differ from the surface behind them. Two habits it enforces, both load-bearing:
- Freeze animated marks at a chosen phase (
--phase), or two runs sample different instants and
are not comparable. A dot that breathes 0.4→0.9 opacity looks like a different mark each shot.
- Read ink and contrast as different questions. Ink answers how big does this read; peak contrast
answers how loud. The fix for "the quiet dot looks small" is to match the INK and let tone/opacity
carry the quietness — never to shrink or dim the geometry. Size says "same kind of mark"; tone says
"quieter". Only tone may vary.
Rule 3 — distrust a measurement that is suspiciously large
A 3.5px error on an 11.5px font is ~30% — that is not a subtle misalignment, it is a broken
instrument. The first run of the routine above reported exactly that, and the cause was the flex-item
baseline probe described in Rule 2. Before acting on any measurement, sanity-check the magnitude
against what you can SEE in the screenshot. If the number claims a gross error and the picture shows
a subtle one, the number is wrong — fix the instrument, not the UI.
This is the visual instance of the repo-wide rule that a confirming or dramatic result is the moment
to get suspicious, not relieved.
Rule 4 — mirror the real product before inventing a treatment
If the thing you are drawing exists in a product the user knows (GitHub, Linear, the app's own
existing components), go look at the real one and measure it rather than designing from taste.
Drive the real site headless and read the computed values out of the DOM:
node scripts/shot.mjs "https://github.com/OWNER/REPO/issues" .adhoc-shots/real.png \
"(() => { const s=document.querySelector('svg.octicon-git-pull-request'); const h=s.parentElement; \
return { svg: s.outerHTML, fill: getComputedStyle(s).fill, color: getComputedStyle(h).color, \
fontSize: getComputedStyle(h).fontSize, text: h.textContent.trim() } })()" --wait=4000
That single call settled a badge that had been guessed wrong twice: GitHub renders a filled
octicon (not a lucide stroke glyph), monochrome rgb(145,152,161) matching the comment badge
beside it (not state-coloured green/purple), showing a count (not #number). The general lesson:
colour belongs to an item's own state, not to its links — and you learn that by reading the real
DOM, not by reasoning about it.
Rule 5 — geometry decides occlusion; the eye decides balance
Both passes, every time. Geometry catches what the eye cannot (z-index, overflow: hidden, clipping,
a box escaping its row at a narrow width). The eye catches what geometry cannot (a glyph that is
technically centered and visually heavy, a pill whose rounded cap eats its padding, ink that rides
high in the em). Neither substitutes for the other, and "the numbers are equal" is never the verdict
on whether something looks right.
Useful geometry assertions to run alongside the ink measurement:
(() => { const r=document.querySelector("ROW"), a=r.querySelector("BADGE"), t=r.querySelector("TITLE")
const ab=a.getBoundingClientRect(), tb=t.getBoundingClientRect(), rb=r.getBoundingClientRect()
return { overlapsTitle: ab.left < tb.right, insideRow: ab.right <= rb.right + 1 } })()
(() => { const a=document.querySelector("NEW"), b=document.querySelector("SIBLING")
return { toneMatches: getComputedStyle(a).color === getComputedStyle(b).color,
sizeMatches: getComputedStyle(a).fontSize === getComputedStyle(b).fontSize } })()
Checklist before you call UI done