Skip to content
RU
Scale
Density
Theme
Accent

About

Pixel audit

tools/audit.js measures contrast and tap targets by what is drawn in the browser. It is an addition to the textual checks: those parse tokens.css, the pages of the documentation and the built files, but between the declaration of a token and a pixel stand nesting, translucent layers, the application's cascade and the chosen theme.

The check walks every node that carries text, takes its real colour and its real background, gathering the alpha up the ancestors, and computes the contrast. A colour in any notation — oklch, color-mix, light-dark — is resolved to sRGB by the browser itself: the value is painted on a canvas one pixel in size and read back.

It has to be run on your own screen: a list of pairs for a textual check cannot be drawn up in advance, because the number of combinations depends on how the layers are nested in a particular application.

How to run it

Open your own screen, paste tools/audit.js into the browser console and call:

kitAudit.run()            // the whole page: every axis at once
kitAudit.run('#main')     // a part of it
kitAudit.contrast()       // one pass at the current theme and accent
kitAudit.targets()        // one pass at the current scale and density
kitAudit.proportion()     // the same pass: does the text fit its box

run() prints a summary as a table and unfolds the violations by cell. It returns the same as an object, if the result has to be parsed by a program.

The axes are not multiplied all by all, and that is deliberate. Contrast goes by theme × accent: an accent redefines the --a-* ramp, and a label on an accent fill is different in every theme. Targets and proportions go by scale × density: both axes move geometry and both round to whole pixels independently of each other. Multiplying one by the other would stretch the run twentyfold to repeat the same numbers.

What it measures

The contrast of text 4.5:1, and 3:1 for large text (24px, or 18.66px in semibold). The background is gathered up the ancestors, and translucent layers are composited in the order of drawing
Tap targets 24×24 under WCAG 2.2 AA (2.5.8) with two exceptions of the criterion: by spacing — a target smaller than the norm counts when S + G ≥ 24; inline — a link inside a line of text is exempt entirely
Proportions Whether a label fits its column and whether it has wrapped onto a second line; the ink of a glyph against the cap height of the label beside it. This is the one thing that cannot be derived from the tokens: the width of a line depends on the family, the weight and the language
Themes and accents 5 × 4 for contrast. A theme set on a subtree stays its own: the root one does not override it
Scales and densities 5 × 3 for targets and proportions

What it does not see

The hidden A closed popover, a folded panel, a second tab — they are not in the layout. Open them and run it again
A background as an image Only colour is counted. Text over an image is skipped by the check
Order and meaning It measures pixels. The keyboard, the names of roles and the order of reading stay with a human
States Hover, focus and press live at the moment of an interaction. What is measured is what is visible now

When the result is not to be trusted

The result depends on the moment of the measurement. Five cases give false violations.

A measurement during an animation. A change of theme is animated, and a colour read in the same frame is an interpolation. The mark: the browser returns the colour as oklab(…) rather than oklch(…). run() suppresses the transitions itself, contrast() does not.

Fractional widths. A button of a segmented control in the compact mode is 22px of height and 2px of gap, exactly 24. At a fractional width the browser gives back a gap of 1.9999, and the norm formally fails. A tolerance of 0.1px suppresses that; there is no margin there, and an application that squeezes the gap slightly will break WCAG in silence.

A tap area grown by a pseudo-element. Small controls stay small (the glyph of a checkbox is 13px), and what grows them to the norm is an invisible ::before. The check takes the largest of the element and its absolutely-positioned pseudo-elements; an inline pseudo-element decorates rather than widening the target.

A sticky header over a scroll. The spacing is measured in the coordinates of the window, and an element travelling over another reads as standing flush against it. Intersecting boxes are layers rather than neighbours, and the check does not compare them.

Visibly hidden controls. A "skip to content" link and labels for screen readers live as a box one pixel in size with a clip-path, and become a real target on focus alone. The mark is the clip-path: inset(50%) clipping rather than the size: an element with border-box cannot become thinner than its borders, so a declared 1px is drawn as 2px.

Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument