Find what only exists once a page is rendered: a page that scrolls sideways, text cut off with no ellipsis or pushed off the screen, a focus ring an ancestor clips, text that fails contrast on the surface it actually landed on, a translation key on screen, and right-to-left leaks. Orphan words are reported separately and never fail the run.
Add "Rendered screen checks" from ntent to this repo.
Fetch https://ntent.app/r/f/check-screens as plain text. Write it verbatim to scripts/check-screens.mjs. Then make exactly these edits and no others: Set CONFIG.baseUrl to where this app runs locally and CONFIG.routes to the routes worth measuring. If dark mode is an attribute or a class rather than the system setting, set CONFIG.theme.strategy to match. It needs Playwright with Chromium installed and a web app to run it against. If this repo does not have them, stop and tell me.
Then check it: Start the app and run it with no arguments: it prints each route with its findings, or says it is clean and names what it could not check. Put a fixed-width box wider than 390px on a page and it names the box and says the page scrolls sideways. Stop the app and it exits 2, saying nothing answered, rather than reporting a pass.Reads https://ntent.app/r/f/check-screens
Read the code
#!/usr/bin/env node
/**
* Screen check: what goes wrong only once a legal value meets a real box.
*
* Every other check here reads source. The tokens check proves a colour is a
* token, the locale check proves a key exists, the lint rules prove nobody wrote
* a raw size. None of them can answer a question about a PAIR of values, or
* about a value's relationship to the 390px screen it landed on, because
* neither exists until the page is rendered. Each of those checks says so in
* its own WHAT IT DOES NOT PROVE, and ends "look at the screen". This is the
* part of looking that a script can do.
*
* The detectors were built for a design board of phone screens in an earlier
* repo, and the bug that prompted them is the shape of all of them: two
* containers each paid the same top inset, both correct in their own file, and
* 311 screens carried a strip of empty space for two days. No source check
* could see it. Four lines of getComputedStyle could.
*
* TEN DETECTORS, ONE BROWSER PASS. Loading the page is the cost, not the
* measuring, so they share the load. Run one with --only.
*
* overflow The page scrolls sideways. Names the outermost box that
* crosses the edge and the furthest thing inside it, which
* is usually a long unbreakable string or a flex child whose
* minimum width is its content.
* clipped Text cut off by overflow: hidden with NO ellipsis to say
* so. Deliberate truncation sets text-overflow or a line
* clamp and is not reported. This is the accidental kind,
* where a box is simply too small and swallows a word.
* offscreen-text Text whose glyphs run past the screen edge where nothing
* can scroll to them. overflow asks about boxes; this asks
* about words, after every clip on the way, so an
* ellipsised line is never reported however long it is.
* unasked-clamp Text cut at a line count nobody chose. A clamp a caller
* asked for is a decision. A component's DEFAULT clamp
* cutting a sentence is a bug nobody decided, and the DOM
* cannot tell the two apart, so your components have to say
* which is which with CONFIG.clampMarker. Until one does,
* this detector checks nothing, and the run says so.
* clipped-ring A focus ring cut off by an ancestor that clips. Rings are
* drawn outside the box, any overflow: hidden above takes a
* side away, and it only exists on focus, so no screenshot
* review ever sees it. The check presses Tab through the
* page, the way a keyboard user meets it.
* contrast Text measured against the background it ACTUALLY landed
* on, composited through translucent ancestors. A contrast
* table proves every declared pair; it cannot see a correct
* token used on the wrong surface, which is the failure that
* reaches a screen.
* raw-key A translation key rendered as its own text, or an
* unfilled {{placeholder}}. The locale check resolves every
* LITERAL key; a key built at runtime that misses renders as
* its own path, and only the page knows.
* direction-leak In a right-to-left render, a subtree hard-set to ltr
* around right-to-left words, or text pinned left with a
* physical text-align. Layout follows the UI language, so
* both reorder or misalign the words against the screen.
* tracked-script Arabic script set with letter-spacing, which breaks the
* joins of a connected script. Display styles carry
* negative tracking and caps labels positive, and either
* one lands on an Arabic name eventually.
* orphan SOFTER, and does not fail --check. A wrapped string whose
* last line holds one word. It is a copy rule rather than a
* defect, it moves with the font and the width, and it only
* exists once the text meets a box.
*
* A NAG, NOT A PRE-COMMIT GATE. It needs a running app and a browser, and a
* fresh clone has neither. A gate people cannot satisfy is one they learn to
* skip. Run it before review, after a copy or layout change, or in CI against a
* preview deploy.
*
* EXIT CODES. 0 clean, 1 findings (with --check), 2 cannot check. Playwright
* not installed, no browser, nothing listening at the base URL, or no routes to
* visit all exit 2, so "I could not look" is never reported as "it looked
* fine". A route that answers 404 or 500 was not measured either, and the run
* says which.
*
* WHAT THIS DOES NOT PROVE. That the screen looks right. It measures the
* routes you list, at the widths and modes you list, in the state they load in:
* no hover, no open menu, no signed-in state unless the route renders one, and
* Chromium only. It cannot see pseudo-elements, text drawn into an image or a
* canvas, or text sitting over a picture: contrast reads the ancestor chain,
* not the pixels, and it says how many texts it could not measure rather than
* guessing. A clean run narrows where to look. It does not replace looking.
*
* Usage:
* node scripts/check-screens.mjs report, exit 0
* node scripts/check-screens.mjs --check exit 1 on findings
* node scripts/check-screens.mjs --base-url http://localhost:3001 --routes /,/pricing
* node scripts/check-screens.mjs --widths 390 --modes dark --only contrast
* node scripts/check-screens.mjs --rtl add a right-to-left render
* node scripts/check-screens.mjs --json
*
* Needs Playwright and a Chromium build in the repo that runs it:
* pnpm add -D playwright && pnpm exec playwright install chromium
*/
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
// ---------------------------------------------------------------- CONFIG ---
export const CONFIG = {
// Where the app is running. --base-url overrides it.
baseUrl: 'http://localhost:3000',
// Every route to measure, as paths. --routes and bare arguments replace this
// list for one run. A route that needs data to render needs that data seeded
// first, or it measures an empty state.
routes: ['/'],
// The matrix. Every route is measured once per width, per mode, per
// direction. 390 is a current phone, 1280 a laptop.
widths: [390, 1280],
height: 900,
modes: ['light', 'dark'],
// How the app is put into dark mode.
// 'media' prefers-color-scheme is emulated, and nothing else is done.
// Right for an app that follows the system setting, including
// one whose pre-paint script reads it into an attribute.
// 'attribute' also set theme.attribute on <html> to the mode's value
// 'class' also put the mode's value in <html>'s class list, and take
// the other mode's out (Tailwind's `dark` class)
// storageKey, when set, is written to localStorage before the page loads,
// for an app that remembers a choice there and would otherwise ignore the
// emulated setting.
theme: {
strategy: 'media',
attribute: 'data-theme',
values: { light: 'light', dark: 'dark' },
storageKey: null,
},
// A right-to-left render, added to the matrix when enabled (or with --rtl).
// Set routePrefix ('/ar') or query ('lang=ar') when the app has a real RTL
// locale; with neither, dir="rtl" is set on <html> after load, which mirrors
// the layout around your own copy. That mirrored-English render still finds
// overflow and physical alignment, but direction-leak's first clause needs
// right-to-left words on the page and can only fire on a real locale.
rtl: {
enabled: false,
routePrefix: null,
query: null,
},
// Subtrees every detector skips. For a third-party widget, or the framework's
// dev overlay: Next.js mounts its indicator in <nextjs-portal>.
ignore: ['[data-screen-check-ignore]', 'nextjs-portal'],
// WCAG AA. A project whose contract holds some text roles to 3 on purpose
// (metadata, captions) will see those reported at 4.5, because a rendered
// pixel carries no role. Either lower `text` to 3 and look at body copy by
// eye, or accept the specific pairs below with a reason.
contrast: { text: 4.5, large: 3 },
// The attribute your text component renders when it applies its OWN default
// line clamp rather than one its caller passed. unasked-clamp reads nothing
// else. The value names the slot, for the report: data-default-clamp="title".
clampMarker: 'data-default-clamp',
i18n: {
// The base locale, first one that exists wins. With one, raw-key matches
// rendered text against the real key list, which is exact. With none, it
// falls back to the shape below, which is a guess and says so.
localeFiles: ['messages/en.json', 'locales/en.json', 'public/locales/en/common.json', 'src/locales/en.json'],
// Three or more dotted segments, each starting with a letter, no spaces,
// with an optional i18next namespace. Two segments is too close to a file
// name or a product name to be worth the noise.
keyPattern: '^(?:[a-z][\\w-]*:)?[a-z][A-Za-z0-9_]*(?:\\.[A-Za-z][A-Za-z0-9_-]*){2,}$',
// A last segment that makes a dotted string a domain or a file, not a key.
notKeys: ['com', 'org', 'net', 'io', 'dev', 'app', 'co', 'ai', 'uk', 'json', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'md', 'mdx', 'css', 'html', 'txt', 'pdf', 'png', 'jpg', 'svg', 'yml', 'yaml', 'sh'],
},
detectors: [
'overflow',
'clipped',
'offscreen-text',
'unasked-clamp',
'clipped-ring',
'contrast',
'raw-key',
'direction-leak',
'tracked-script',
'orphan',
],
// Softer findings print in their own section and never fail --check.
soft: ['orphan'],
// Tab presses per page before the ring walk gives up. A page with more stops
// than this is walked in part, and the run says so.
maxFocusStops: 150,
// After load, fonts and a network lull, a pause for anything late to land.
settleMs: 250,
timeoutMs: 60000,
}
/**
* Findings you have decided to keep. Every entry carries WHY and WHEN it stops
* being acceptable, both are enforced, every accepted finding prints on every
* run, and an entry past its date counts as a finding again. `match` is a
* substring of the finding's element, message or colour pair; `route` is a path
* or '*'.
*/
const ACCEPTED = [
// {
// route: '/pricing',
// detector: 'contrast',
// match: 'rgb(113, 113, 122) on rgb(244, 244, 245)',
// reason: 'The legal footnote under the plan table. Held to 3:1 by the brand contract; revisit with the new neutral ramp.',
// until: '2026-12-31',
// },
]
// ---------------------------------------------------------- IN THE PAGE ---
//
// Everything below this line down to NODE runs inside the browser. Playwright
// ships a function across by its source text, so none of these may close over
// anything in this module: each gets the helpers object `h` and nothing else.
// They are exported, and named, so the tests can run each one on its own.
/** The helpers every detector shares. Pure until one of them is called. */
export function pageHelpers(opts = {}) {
const IGNORE = (opts.ignore ?? []).join(', ')
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head', 'title', 'meta', 'link'])
const CODE = 'code, pre, kbd, samp, textarea'
const SVG = 'http://www.w3.org/2000/svg'
const keySet = new Set(opts.keys ?? [])
const keyPattern = new RegExp(opts.keyPattern ?? '^$')
const notKeys = new Set(opts.notKeys ?? [])
const tally = {}
// -- colour ----------------------------------------------------------------
/** rgb()/rgba(), both the comma and the space syntax. */
const parseRgb = (value) => {
const m = /^rgba?\(\s*([^)]+)\)$/.exec(String(value).trim())
if (!m) return null
const parts = m[1].split(/[\s,/]+/).filter(Boolean)
if (parts.length < 3) return null
const num = (p, scale) => (p.endsWith('%') ? (Number.parseFloat(p) / 100) * scale : Number.parseFloat(p))
const [r, g, b] = parts.slice(0, 3).map((p) => num(p, 255))
const a = parts.length > 3 ? num(parts[3], 1) : 1
if ([r, g, b, a].some((n) => Number.isNaN(n))) return null
return { r, g, b, a }
}
// ANY CSS COLOUR, AS sRGB. Chromium reports a computed colour in the space it
// was written in, so a Tailwind v4 palette comes back as oklch() and a
// color-mix() as color(srgb ...). Parsing every space by hand is a colour
// library; painting one pixel and reading it back is four lines and is
// exactly the conversion the screen does.
let pixel = null
const seen = new Map()
const color = (value) => {
if (!value) return null
const direct = parseRgb(value)
if (direct) return direct
if (value === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }
if (seen.has(value)) return seen.get(value)
if (!pixel) {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 1
pixel = canvas.getContext('2d', { willReadFrequently: true })
}
pixel.clearRect(0, 0, 1, 1)
pixel.fillStyle = 'rgba(0, 0, 0, 0)'
pixel.fillStyle = value
pixel.fillRect(0, 0, 1, 1)
const [r, g, b, a] = pixel.getImageData(0, 0, 1, 1).data
const out = a === 0 ? { r: 0, g: 0, b: 0, a: 0 } : { r, g, b, a: a / 255 }
seen.set(value, out)
return out
}
/** top painted over an opaque bottom. */
const over = (top, bottom) => ({
r: top.r * top.a + bottom.r * (1 - top.a),
g: top.g * top.a + bottom.g * (1 - top.a),
b: top.b * top.a + bottom.b * (1 - top.a),
a: 1,
})
const luminance = ({ r, g, b }) => {
const channel = (value) => {
const v = value / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}
const contrast = (fg, bg) => {
const a = luminance(fg)
const b = luminance(bg)
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
const rgbText = ({ r, g, b }) => `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`
// -- rings -----------------------------------------------------------------
// Chromium serialises box-shadow as `<color> <x> <y> <blur> <spread>`, comma
// separated, and the colour holds commas of its own. Split on the commas
// outside parentheses.
const splitShadows = (value) => {
const out = []
let depth = 0
let start = 0
for (let i = 0; i < value.length; i++) {
if (value[i] === '(') depth++
else if (value[i] === ')') depth--
else if (value[i] === ',' && depth === 0) {
out.push(value.slice(start, i))
start = i + 1
}
}
out.push(value.slice(start))
return out.map((part) => part.trim()).filter(Boolean)
}
/** How far a ring-shaped shadow reaches outside the box. An inset ring, or
* one with an offset, is not a ring hanging off every edge. */
const shadowExtent = (value) => {
if (!value || value === 'none') return 0
let extent = 0
for (const shadow of splitShadows(value)) {
if (/\binset\b/.test(shadow)) continue
const lengths = [...shadow.matchAll(/(-?[\d.]+)px/g)].map((m) => Number(m[1]))
if (lengths.length < 4) continue
const [x, y, , spread] = lengths
if (x !== 0 || y !== 0) continue
extent = Math.max(extent, spread)
}
return extent
}
const outlineExtent = (style) => {
if (!style.outlineStyle || style.outlineStyle === 'none') return 0
const width = Number.parseFloat(style.outlineWidth) || 0
if (width <= 0) return 0
return (Number.parseFloat(style.outlineOffset) || 0) + width
}
// -- words -----------------------------------------------------------------
/** 'identity', 'placeholder', 'shape', or null. */
const keyKind = (text) => {
const t = text.trim()
if (!t) return null
if (keySet.has(t) || keySet.has(t.replace(/^[\w-]+:/, ''))) return 'identity'
if (/\{\{\s*[\w.-]+\s*\}\}/.test(t)) return 'placeholder'
if (!keyPattern.test(t)) return null
const last = t.split('.').pop()
return notKeys.has(last.toLowerCase()) ? null : 'shape'
}
// A single unbreakable token is not an orphan anyone can fix by rephrasing:
// an address, a path, a hash. The line broke where the string gave it no
// choice, and reporting it teaches people to skim.
const unbreakable = (word) => /[@=<>/\\]/.test(word) || word.replace(/[.,;:?!)]+$/, '').length > 24
// -- the DOM ---------------------------------------------------------------
let accepted = null
/** Every element a detector may look at, in document order. */
const elements = () => {
if (accepted) return accepted
accepted = []
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
acceptNode: (el) => {
const tag = el.tagName.toLowerCase()
if (SKIP_TAGS.has(tag)) return NodeFilter.FILTER_REJECT
if (IGNORE && el.matches(IGNORE)) return NodeFilter.FILTER_REJECT
if (el.namespaceURI === SVG && tag !== 'svg') return NodeFilter.FILTER_REJECT
return NodeFilter.FILTER_ACCEPT
},
})
for (let node = walker.nextNode(); node; node = walker.nextNode()) accepted.push(node)
return accepted
}
const ignoredRoots = () => (IGNORE ? [...document.body.querySelectorAll(IGNORE)] : [])
let acceptedSet = null
const accepts = (el) => {
if (!acceptedSet) acceptedSet = new Set(elements())
return acceptedSet.has(el)
}
/** Every non-empty text node under an accepted element. */
const textNodes = ({ skipCode = false } = {}) => {
const out = []
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = (node.textContent ?? '').trim()
const parent = node.parentElement
if (!text || !parent || !accepts(parent)) continue
if (skipCode && parent.closest(CODE)) continue
out.push({ node, text, parent })
}
return out
}
const ownText = (el) =>
[...el.childNodes]
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent ?? '')
.join('')
.trim()
const clip = (value, n = 40) => {
const text = String(value).trim().replace(/\s+/g, ' ')
return `"${text.slice(0, n)}${text.length > n ? '…' : ''}"`
}
/** A short handle a person can find: tag, test id or id or a class or two,
* and the words it holds. */
const describe = (el) => {
if (el === document.documentElement) return '<html>'
if (el === document.body) return '<body>'
const tag = el.tagName.toLowerCase()
const testId = el.getAttribute('data-testid')
const classes = [...el.classList].filter((c) => /^[a-z][\w-]*$/i.test(c)).slice(0, 2)
const handle = testId ? `[data-testid="${testId}"]` : el.id ? `#${el.id}` : classes.map((c) => `.${c}`).join('')
const said = ownText(el) || el.getAttribute('aria-label') || (el.textContent ?? '')
return `<${tag}${handle}>${said.trim() ? ` ${clip(said)}` : ''}`
}
const visible = (el) => {
const style = getComputedStyle(el)
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return false
if (Number(style.opacity) === 0) return false
const rect = el.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
// HIDDEN FROM EVERYONE WHO LOOKS. A screen-reader-only label is a 1px box
// with overflow hidden and a clip rect: it is "cut off" by design, and the
// first thing a clipped-text detector finds on any accessible page. So is
// anything under opacity 0, which is how a measuring ghost is drawn.
const concealed = (el) => {
for (let node = el; node && node !== document.documentElement; node = node.parentElement) {
const style = getComputedStyle(node)
if (Number(style.opacity) === 0 || style.visibility === 'hidden') return true
if (style.clip && style.clip !== 'auto') return true
if (/inset\(\s*50%/.test(style.clipPath ?? '')) return true
if (style.overflow !== 'visible') {
const rect = node.getBoundingClientRect()
if (rect.width <= 1 || rect.height <= 1) return true
}
}
return false
}
// A TRANSFORM THAT MOVES NOTHING IS NOT A STATE. A row dragged sideways, a
// drawer slid out: their position is a state, not a layout, and a box that
// crosses the edge that way is not a defect. But a compositing hint such as
// translateZ(0) computes to an identity matrix, and reading THAT as "moved"
// made every node inside a scroll view look mid-drag: the overflow detector
// was blind to all of a screen's content, and an email address ran 236pt off
// both edges of a phone while the sweep called it clean. The 2D part of the
// matrix is what moves a box; a Z translation or an identity moves nothing.
const moves = (el) => {
const transform = getComputedStyle(el).transform
if (!transform || transform === 'none') return false
try {
const m = new DOMMatrixReadOnly(transform)
return !(m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0)
} catch {
return true
}
}
const movedByTransform = (el) => {
for (let node = el; node && node !== document.documentElement; node = node.parentElement) {
if (moves(node)) return true
}
return false
}
/** The ancestors between an element and the page, nearest first. The body
* and the root are left out: their overflow belongs to the viewport. */
const ancestors = (el) => {
const out = []
for (let node = el.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {
out.push(node)
}
return out
}
const insideFixed = (el) => {
for (let node = el; node && node !== document.documentElement; node = node.parentElement) {
if (getComputedStyle(node).position === 'fixed') return true
}
return false
}
const scrollerX = (el) =>
ancestors(el).some((node) => ['auto', 'scroll'].includes(getComputedStyle(node).overflowX))
const underClipX = (el) => ancestors(el).some((node) => getComputedStyle(node).overflowX !== 'visible')
/** Does the viewport itself hide horizontal overflow? <html> first, then
* <body>'s, which propagates to the viewport when <html>'s is visible. */
const viewportClipsX = () => {
const root = getComputedStyle(document.documentElement).overflowX
const body = getComputedStyle(document.body).overflowX
const clips = (v) => v === 'hidden' || v === 'clip'
return clips(root) || (root === 'visible' && clips(body))
}
const rtl = () => getComputedStyle(document.body).direction === 'rtl'
/** The box a clip actually cuts at: the border box less the border. */
const paddingBox = (el) => {
const box = el.getBoundingClientRect()
const style = getComputedStyle(el)
return {
top: box.top + (Number.parseFloat(style.borderTopWidth) || 0),
bottom: box.bottom - (Number.parseFloat(style.borderBottomWidth) || 0),
left: box.left + (Number.parseFloat(style.borderLeftWidth) || 0),
right: box.right - (Number.parseFloat(style.borderRightWidth) || 0),
}
}
const rects = (node) => {
const range = document.createRange()
range.selectNodeContents(node)
return [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0)
}
const count = (name, n = 1) => {
tally[name] = (tally[name] ?? 0) + n
}
return {
opts,
parseRgb,
color,
over,
luminance,
contrast,
rgbText,
splitShadows,
shadowExtent,
outlineExtent,
keyKind,
unbreakable,
elements,
ignoredRoots,
accepts,
textNodes,
ownText,
clip,
describe,
visible,
concealed,
moves,
movedByTransform,
ancestors,
insideFixed,
scrollerX,
underClipX,
viewportClipsX,
rtl,
paddingBox,
rects,
count,
counts: () => tally,
}
}
/**
* The page scrolls sideways. Reported only when it really does: a box past the
* edge inside something that clips or scrolls is contained, and one past the
* edge of a page whose viewport hides overflow is offscreen-text's claim.
*
* Named at the OUTERMOST box that crosses, because that is where the width
* stops being contained, with the furthest thing inside it, because that is
* usually what forced it: a URL with no break opportunity, a table, or a flex
* child whose automatic minimum width is its content.
*/
export function detectOverflow(h) {
const vw = document.documentElement.clientWidth
const scrolling = document.scrollingElement ?? document.documentElement
const wide = scrolling.scrollWidth - vw
if (wide <= 1 || h.viewportClipsX()) return []
const rtl = h.rtl()
// Past the edge the page scrolls towards: the right in LTR, the left in RTL.
// A box past the other edge is unreachable rather than scrollable, and that
// is offscreen-text's claim.
//
// THE BOX OR ITS OWN TEXT. A paragraph holding a long URL is exactly as wide
// as the screen; it is the line inside it that runs on, so a box-only test
// finds nothing to name on the commonest cause there is.
const past = (el) => {
let far = rtl ? -el.getBoundingClientRect().left : el.getBoundingClientRect().right - vw
for (const node of el.childNodes) {
if (node.nodeType !== Node.TEXT_NODE || !(node.textContent ?? '').trim()) continue
for (const r of h.rects(node)) far = Math.max(far, rtl ? -r.left : r.right - vw)
}
return far
}
// NO EXEMPTION FOR A TRANSFORM HERE, unlike the text detectors. The page
// really does scroll, and a drawer translated off the right edge with
// nothing clipping it is the textbook cause of a phone page that wobbles
// sideways. A fixed box does not add to the page's width, so it is left out.
const crossing = (el) => past(el) > 1 && h.visible(el) && !h.concealed(el) && !h.insideFixed(el) && !h.underClipX(el)
const heads = h.elements().filter((el) => {
if (!crossing(el)) return false
const parent = el.parentElement
return !parent || parent === document.body || !crossing(parent)
})
const edge = rtl ? 'left' : 'right'
const fix =
'Find what sets the width: a fixed width or min-width, a long unbreakable string ' +
'(overflow-wrap: anywhere), or a flex/grid child whose minimum is its content ' +
'(min-width: 0, minmax(0, 1fr)). Or give that one box its own overflow-x: auto.'
if (heads.length === 0) {
// Ignoring a subtree stops it being reported on. It does not stop it
// widening the page, and the page really does scroll.
const ignored = h
.ignoredRoots()
.find((root) => [root, ...root.querySelectorAll('*')].some((el) => past(el) > 1 && h.visible(el) && !h.insideFixed(el)))
if (ignored) {
return [
{
element: h.describe(ignored),
message: `is skipped by CONFIG.ignore, and still makes the page ${Math.round(scrolling.scrollWidth)}px wide on a ${vw}px screen.`,
fix,
},
]
}
return [
{
element: '<html>',
message: `the page is ${Math.round(scrolling.scrollWidth)}px wide on a ${vw}px screen and no element could be named. Look for a pseudo-element, a negative margin, or something positioned past the edge.`,
fix,
},
]
}
return heads.map((head) => {
// The furthest crossing box inside it with nothing crossing inside that.
let leaf = null
for (const el of head.querySelectorAll('*')) {
if (!h.accepts(el) || !crossing(el)) continue
if ([...el.children].some((child) => h.accepts(child) && crossing(child))) continue
if (!leaf || past(el) > past(leaf)) leaf = el
}
const inside = leaf && leaf !== head ? ` The furthest thing in it is ${h.describe(leaf)}.` : ''
return {
element: h.describe(head),
message: `runs ${Math.round(past(head))}px past the ${edge} edge of a ${vw}px screen, and the page scrolls sideways.${inside}`,
fix,
}
})
}
/**
* Text cut off by overflow: hidden with no ellipsis and no clamp. Only a line
* the clip CUTS, part shown and part hidden, is reported. A line wholly outside
* the box is how a carousel hides its other slides and a disclosure hides its
* body, and reporting it would convict every one.
*/
export function detectClipped(h) {
const out = []
for (const el of h.elements()) {
const style = getComputedStyle(el)
const clipX = style.overflowX === 'hidden' || style.overflowX === 'clip'
const clipY = style.overflowY === 'hidden' || style.overflowY === 'clip'
if (!clipX && !clipY) continue
if (!h.visible(el) || h.concealed(el)) continue
// The wrapper around a row dragged sideways clips on purpose. It is the
// child that moved, so the child is checked as well as the wrapper.
if (h.movedByTransform(el) || [...el.children].some(h.moves)) continue
// A fade says "there is more" as plainly as an ellipsis does.
if ((style.maskImage ?? 'none') !== 'none' || (style.webkitMaskImage ?? 'none') !== 'none') continue
const saysSoX = style.textOverflow === 'ellipsis'
const saysSoY = style.webkitLineClamp && style.webkitLineClamp !== 'none'
const box = h.paddingBox(el)
let cut = 0
let axis = ''
// Text this box clips itself. A nested clipping box is judged on its own.
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const parent = node.parentElement
if (!(node.textContent ?? '').trim() || !parent || !h.accepts(parent) || !h.visible(parent)) continue
let nearest = parent
while (nearest !== el && getComputedStyle(nearest).overflow === 'visible') nearest = nearest.parentElement
if (nearest !== el) continue
for (const r of h.rects(node)) {
if (clipX && !saysSoX) {
const right = r.left < box.right && r.right > box.right ? r.right - box.right : 0
const left = r.right > box.left && r.left < box.left ? box.left - r.left : 0
const amount = Math.max(right, left)
if (amount > 1 && amount > cut) {
cut = amount
axis = 'width'
}
}
// A range rect is the font's glyph box, which runs taller than a tight
// line height, so a single line in a box exactly one line tall pokes
// out by a pixel or three. Only a line losing a real part of itself
// counts as cut.
if (clipY && !saysSoY) {
const bottom = r.top < box.bottom && r.bottom > box.bottom ? r.bottom - box.bottom : 0
if (bottom > Math.max(2, r.height * 0.4) && bottom > cut) {
cut = bottom
axis = 'height'
}
}
}
}
if (!cut) continue
out.push({
element: h.describe(el),
message: `cuts ${Math.round(cut)}px off its text across its ${axis}, with no ellipsis to say so.`,
fix: 'Let it wrap or give it room. If it is meant to truncate, say so on screen with text-overflow: ellipsis or a line clamp.',
})
}
return out
}
/**
* Glyphs past the screen edge where nothing can scroll to them, measured on the
* text itself. The visible part of each line is its range rect CUT BY EVERY
* CLIPPING ANCESTOR, so an ellipsised line is never reported, and a line cut by
* a card well inside the screen is clipped's claim, not this one. A container
* whose clip edge IS the screen's edge counts as the screen: the first version
* honoured every clip and went silent on exactly the case it was built for.
*/
export function detectOffscreenText(h) {
const out = []
const vw = document.documentElement.clientWidth
const rtl = h.rtl()
// When the page itself scrolls sideways, the side it scrolls towards is
// reachable, and the overflow detector has already said so.
const scrolling = document.scrollingElement ?? document.documentElement
const pageScrolls = scrolling.scrollWidth - vw > 1 && !h.viewportClipsX()
const reported = new Set()
for (const { node, text, parent } of h.textNodes()) {
if (reported.has(parent)) continue
if (!h.visible(parent) || h.concealed(parent)) continue
if (h.movedByTransform(parent) || h.scrollerX(parent)) continue
const clips = []
for (const box of [parent, ...h.ancestors(parent)]) {
const style = getComputedStyle(box)
if (style.overflowX === 'visible' && style.overflow === 'visible') continue
const edge = h.paddingBox(box)
// A box that says it truncates is honoured wherever its edge falls: a
// full-width title with an ellipsis ends at the screen edge by design.
const says = style.textOverflow === 'ellipsis'
clips.push({
left: says || edge.left > 1 ? edge.left : -Infinity,
right: says || edge.right < vw - 1 ? edge.right : Infinity,
})
}
let left = 0
let right = 0
let onScreen = false
for (const r of h.rects(node)) {
let l = r.left
let rr = r.right
for (const c of clips) {
l = Math.max(l, c.left)
rr = Math.min(rr, c.right)
}
if (rr - l <= 1) continue
if (Math.min(rr, vw) - Math.max(l, 0) > 1) onScreen = true
left = Math.max(left, -l)
right = Math.max(right, rr - vw)
}
// Text with no part on screen was PUT there: a skip link parked at
// left: -9999px until it takes focus. Cut off means part shown.
if (!onScreen) continue
if (pageScrolls) {
if (rtl) left = 0
else right = 0
}
const amount = Math.max(left, right)
if (amount <= 1) continue
reported.add(parent)
const side = left > 1 && right > 1 ? 'both edges' : left > right ? 'the left edge' : 'the right edge'
out.push({
element: h.describe(parent),
message: `${h.clip(text)} runs ${Math.round(amount)}px past ${side} of a ${vw}px screen, where nothing can scroll to it.`,
fix: 'Let it wrap (overflow-wrap: anywhere for a long token such as an address), or give its container overflow-x: auto.',
})
}
return out
}
/**
* Text cut at a DEFAULT line count, one its caller never passed. The DOM cannot
* tell a default from a choice, so the component says which: it renders
* CONFIG.clampMarker only when the clamp in force is its own default.
*
* CUT IS MEASURED BY LIFTING THE CLAMP, for one synchronous measurement put
* back before anything paints. The first version compared the box with its ink,
* and reported 153 Arabic titles, none of them cut: a range rect is the font's
* glyph box, and an Arabic face runs taller than the authored line height.
*/
export function detectUnaskedClamp(h) {
const out = []
const marker = h.opts.clampMarker
if (!marker) return out
const marked = [...document.querySelectorAll(`[${marker}]`)].filter((el) => h.accepts(el))
h.count('clamp-marked', marked.length)
for (const el of marked) {
if (!h.visible(el) || h.concealed(el)) continue
const style = getComputedStyle(el)
const lines = style.webkitLineClamp && style.webkitLineClamp !== 'none' ? style.webkitLineClamp : '1'
const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.2 || 16
const clamped = el.getBoundingClientRect().height
const saved = el.getAttribute('style')
el.style.setProperty('-webkit-line-clamp', 'none', 'important')
el.style.setProperty('display', 'block', 'important')
el.style.setProperty('white-space', 'normal', 'important')
el.style.setProperty('max-height', 'none', 'important')
el.style.setProperty('height', 'auto', 'important')
const natural = el.getBoundingClientRect().height
if (saved === null) el.removeAttribute('style')
else el.setAttribute('style', saved)
if (natural - clamped < lineHeight / 2) continue
const slot = el.getAttribute(marker) || 'text'
out.push({
element: h.describe(el),
message: `a ${slot} cut at its component's default of ${lines} line(s), which no caller asked for.`,
fix: 'Pass the line count this screen wants, or none. A default clamp that cuts a sentence is a decision nobody made.',
key: `unasked-clamp|${slot}|${lines}`,
})
}
return out
}
/**
* The focus ring on the element that has focus now, cut by an ancestor that
* clips. Run once per Tab stop by the node side, which is what puts a ring on
* screen at all.
*
* THE CONTROL ITSELF MUST BE INSIDE THE CLIP ON THAT SIDE. Otherwise it is not
* a clipped ring, it is a box that overflows or a field scrolled out of view,
* and the first full run reported one of those as a cut ring: a field at the
* foot of a scroll view, simply below the fold.
*/
export function detectClippedRing(h) {
const el = document.activeElement
if (!el || el === document.body || !h.accepts(el) || !h.visible(el)) return []
const style = getComputedStyle(el)
const extent = Math.max(h.outlineExtent(style), h.shadowExtent(style.boxShadow))
if (extent <= 1) return []
const ring = el.getBoundingClientRect()
for (const node of h.ancestors(el)) {
const ns = getComputedStyle(node)
const x = ns.overflowX !== 'visible'
const y = ns.overflowY !== 'visible'
if (!x && !y) continue
const edge = h.paddingBox(node)
const sides = [
['top', y, edge.top - (ring.top - extent), ring.top >= edge.top - 1],
['bottom', y, ring.bottom + extent - edge.bottom, ring.bottom <= edge.bottom + 1],
['left', x, edge.left - (ring.left - extent), ring.left >= edge.left - 1],
['right', x, ring.right + extent - edge.right, ring.right <= edge.right + 1],
]
.filter(([, clips, amount, inside]) => clips && inside && amount > 1)
.map(([side]) => side)
if (sides.length === 0) continue
// One finding per ring: the outermost clip cuts it no worse than the
// innermost, and naming every one would report one defect four times.
// Keyed on the CONTAINER, so a list of forty rows it clips is one
// finding, and each example says which sides its own ring lost.
return [
{
element: h.describe(node),
message: `clips the focus ring of the controls inside it (overflow: ${ns.overflow}).`,
example: `${h.describe(el)} loses the ${sides.join(', ')} of a ${Math.round(extent)}px ring`,
fix: 'Give the container padding at least as wide as the ring, or draw the ring inside the control (a negative outline-offset, or an inset ring).',
key: `clipped-ring|${h.describe(node)}`,
},
]
}
return []
}
/**
* Text against the background it actually landed on. The walk composites
* every translucent ancestor down onto the first opaque one.
*
* Anything it cannot see through is SKIPPED AND COUNTED, never guessed. A
* gradient has no single colour, and a frosted panel's canvas is whatever is
* behind it: measured as if it were not there, a light toast on a dark slab
* read 1.05:1 against the page white on nine screens. A missing finding is
* recoverable; a confident wrong one teaches people to stop reading.
*
* ONE PAIR IS ONE FINDING, however many strings wear it. An avatar colour ramp
* once reported itself 2,957 times, once per set of initials, for what was a
* single decision about one set of colours.
*/
export function detectContrast(h) {
const out = []
const floors = h.opts.contrast ?? { text: 4.5, large: 3 }
const backdrop = (el) => {
const layers = []
let fade = 1
let base = null
for (let node = el; node; node = node.parentElement) {
const style = getComputedStyle(node)
if (style.backgroundImage && style.backgroundImage !== 'none') return null
if ((style.backdropFilter ?? 'none') !== 'none' || (style.webkitBackdropFilter ?? 'none') !== 'none') return null
if (style.mixBlendMode !== 'normal' || (style.filter ?? 'none') !== 'none') return null
const bg = h.color(style.backgroundColor)
const opacity = Number(style.opacity)
if (bg && bg.a >= 1) {
// An opaque surface that is itself faded shows whatever is under it.
if (opacity < 1) return null
base = bg
break
}
if (bg && bg.a > 0) layers.push(bg)
fade *= opacity
}
if (!base) {
// Nothing opaque up to the root: the canvas. White unless the page
// asked for a dark scheme, whose canvas colour is the browser's choice.
if (/dark/.test(getComputedStyle(document.documentElement).colorScheme ?? '')) return null
base = { r: 255, g: 255, b: 255, a: 1 }
}
let result = base
for (let i = layers.length - 1; i >= 0; i--) result = h.over(layers[i], result)
return { color: result, fade }
}
for (const el of h.elements()) {
const own = h.ownText(el)
if (!own) continue
if (!h.visible(el) || h.concealed(el)) continue
// WCAG exempts inactive controls, and it is right to: a disabled button is
// quiet on purpose, and that quiet is what says it is disabled.
if (el.closest('[aria-disabled="true"], :disabled')) continue
const style = getComputedStyle(el)
const fill = h.color(style.webkitTextFillColor || style.color)
const behind = backdrop(el)
if (!fill || fill.a === 0 || !behind) {
h.count('contrast-unmeasured')
continue
}
const fg = { ...fill, a: fill.a * behind.fade }
const shown = fg.a < 1 ? h.over(fg, behind.color) : fg
const ratio = h.contrast(shown, behind.color)
const size = Number.parseFloat(style.fontSize) || 16
const weight = Number(style.fontWeight) || 400
const large = size >= 24 || (size >= 18.66 && weight >= 700)
const floor = large ? floors.large : floors.text
if (ratio >= floor) continue
const pair = `${h.rgbText(shown)} on ${h.rgbText(behind.color)}`
out.push({
element: `${h.rgbText(shown)} text on ${h.rgbText(behind.color)}`,
message: `${ratio.toFixed(2)}:1, under the ${floor}:1 floor for ${large ? 'large' : 'body'} text.`,
example: `${h.describe(el)} ${Math.round(size)}px/${weight}`,
fix: 'Use a text colour that clears the floor on this surface, or put the text on the surface its colour was paired with.',
key: `contrast|${pair}|${floor}`,
})
}
return out
}
/**
* A translation key on screen. Matched against the real key list when there
* is one: "looks like a dotted key" also matches report.pdf, an email address
* and every version number in the app, and the key list has none of those
* problems.
*/
export function detectRawKey(h) {
const out = []
for (const { text, parent } of h.textNodes({ skipCode: true })) {
const kind = h.keyKind(text)
if (!kind) continue
if (!h.visible(parent) || h.concealed(parent)) continue
const said = h.clip(text, 60)
out.push({
element: h.describe(parent),
message:
kind === 'identity'
? `${said} is a key in the base locale, rendered as its own text.`
: kind === 'placeholder'
? `${said} shows a placeholder nothing filled in.`
: `${said} looks like a translation key rendered as its own text.`,
fix:
kind === 'placeholder'
? 'Pass the value the message interpolates, under the name the message uses.'
: 'A t() call resolved to nothing and returned its key. Usually a key built at runtime, which the locale check cannot see: add the entry, or build the key from a list the check can read.',
})
}
return out
}
/**
* In a right-to-left render: a subtree whose direction was SET to ltr around
* right-to-left words, and text pinned left by a physical text-align. Layout
* follows the UI language, never the content, so both put the words against
* the screen they sit in. A Latin-only island, a URL or a code, is what a
* hard-set ltr is FOR, and is not reported.
*/
export function detectDirectionLeak(h) {
const out = []
if (!h.rtl()) return out
const RTL = /[-ࣿיִ-﷿ﹰ-]/
const origins = new Set()
for (const el of h.elements()) {
if (!el.parentElement || !h.visible(el) || h.concealed(el)) continue
const style = getComputedStyle(el)
const parentStyle = getComputedStyle(el.parentElement)
// dir="auto" resolves from the text's own first strong character, which
// is what a paragraph of user content is supposed to do.
if (el.getAttribute('dir') !== 'auto' && style.direction === 'ltr' && parentStyle.direction === 'rtl') {
if (RTL.test(el.textContent ?? '')) {
out.push({
element: h.describe(el),
message: 'is set to ltr inside a right-to-left page, around right-to-left words.',
fix: 'Remove the hard-set direction, or use dir="auto" where the content decides.',
})
}
}
if (style.direction === 'rtl' && style.textAlign === 'left' && h.ownText(el)) {
// Name where the alignment was SET: the top of the run of boxes that
// inherit it, so one rule on a wrapper is one finding.
let origin = el
while (origin.parentElement && origin.parentElement !== document.body && getComputedStyle(origin.parentElement).textAlign === 'left') {
origin = origin.parentElement
}
if (origins.has(origin)) continue
origins.add(origin)
// GROUPED ON THE CAUSE. A row component with text-left in its classes
// is one rule and one fix, and on a list of fifty rows it was fifty
// findings. Boxes with the same tag and classes share a key.
const signature = `<${origin.tagName.toLowerCase()}${[...origin.classList].map((c) => `.${c}`).join('')}>`
out.push({
element: signature.length > 80 ? `${signature.slice(0, 79)}…>` : signature,
message: 'pins its text left with text-align: left in a right-to-left page.',
example: h.describe(origin),
fix: 'Use text-align: start (Tailwind text-start), which follows the direction.',
key: `direction-leak|text-align|${signature}`,
})
}
}
return out
}
/** Arabic script with letter-spacing, which breaks the joins between letters. */
export function detectTrackedScript(h) {
const out = []
const CONNECTED = /[-ۿݐ-ݿࢠ-ࣿﭐ-﷿ﹰ-]/
for (const { text, parent } of h.textNodes()) {
if (!CONNECTED.test(text)) continue
if (!h.visible(parent) || h.concealed(parent)) continue
const spacing = getComputedStyle(parent).letterSpacing
if (spacing === 'normal' || Number.parseFloat(spacing) === 0) continue
out.push({
element: h.describe(parent),
message: `sets Arabic script with ${spacing} of letter-spacing, which pulls the joined letters apart.`,
fix: 'Set letter-spacing: 0 on text in a connected script, whatever the style it borrows.',
key: `tracked-script|${spacing}`,
})
}
return out
}
/**
* A wrapped string whose last line holds one word. Measured where the lines
* actually broke, with a range per word, rather than guessed from a character
* count: the wrap point moves with the font, the width and the script.
*
* Judged per BLOCK, not per text node. "Read the <a>guide</a> today." is three
* text nodes, and asking each one alone never sees that "today." wrapped by
* itself.
*/
export function detectOrphan(h) {
const out = []
const inline = (el) => ['inline', 'contents'].includes(getComputedStyle(el).display)
const blocks = new Map()
for (const { node, parent } of h.textNodes({ skipCode: true })) {
if (!h.visible(parent) || h.concealed(parent)) continue
let block = parent
while (block.parentElement && block !== document.body && inline(block)) block = block.parentElement
if (!blocks.has(block)) blocks.set(block, [])
blocks.get(block).push(node)
}
for (const [block, nodes] of blocks) {
const style = getComputedStyle(block)
if (style.whiteSpace.startsWith('pre') || style.whiteSpace === 'nowrap') continue
// A line break someone typed is a decision, not a wrap.
if (block.querySelector('br')) continue
// One string across every text node, so a word split by markup
// (<b>Hel</b>lo) is one word, with each character's node remembered.
let joined = ''
const spans = []
for (const node of nodes) {
spans.push({ node, from: joined.length })
joined += node.textContent ?? ''
}
const at = (index) => {
let span = spans[0]
for (const s of spans) if (s.from <= index) span = s
return [span.node, index - span.from]
}
const words = []
for (const m of joined.matchAll(/\S+/g)) {
const range = document.createRange()
range.setStart(...at(m.index))
const [endNode, endOffset] = at(m.index + m[0].length - 1)
range.setEnd(endNode, endOffset + 1)
const found = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0)
if (!found.length) continue
const first = found[0]
const r = found[found.length - 1]
words.push({ word: m[0], index: m.index, mid: (r.top + r.bottom) / 2, height: r.height, split: r.top >= first.bottom - 1 })
}
if (words.length < 2) continue
const heights = words.map((w) => w.height).sort((a, b) => a - b)
const tolerance = Math.max(3, heights[Math.floor(heights.length / 2)] / 2)
let lines = 1
let lineMid = words[0].mid
for (const w of words) {
if (w.mid > lineMid + tolerance) {
lines++
lineMid = w.mid
}
}
if (lines < 2) continue
const onLast = words.filter((w) => Math.abs(w.mid - lineMid) <= tolerance)
if (onLast.length !== 1) continue
const last = onLast[0]
// A URL or an address has to break somewhere, and breaking is right.
if (h.unbreakable(last.word)) continue
// A WORD BROKEN ACROSS THE LAST TWO LINES leaves a fragment alone, not a
// word: word-break: break-all put "produ" on one line and "ct" on the
// next, and calling that an orphan "product" names the wrong fix. Find
// where it broke, one character at a time, and say what is really there.
if (last.split) {
const firstTop = (() => {
const range = document.createRange()
range.setStart(...at(last.index))
const [node, offset] = at(last.index)
range.setEnd(node, offset + 1)
return range.getBoundingClientRect().top
})()
let cut = 1
for (; cut < last.word.length; cut++) {
const range = document.createRange()
const [node, offset] = at(last.index + cut)
range.setStart(node, offset)
range.setEnd(node, offset + 1)
if (range.getBoundingClientRect().top > firstTop + tolerance) break
}
out.push({
element: h.describe(block),
message: `"${last.word}" breaks mid-word, leaving "${last.word.slice(cut)}" alone on the last of ${lines} lines.`,
fix: 'word-break: break-all splits any word at the edge. overflow-wrap: anywhere on its own breaks only a word too long for the line.',
})
continue
}
out.push({
element: h.describe(block),
message: `"${last.word}" sits alone on the last of ${lines} lines.`,
fix: 'text-wrap: pretty on body copy, or balance on a heading, lets the browser fix it. Otherwise rephrase so the last line carries two words.',
})
}
return out
}
export const DETECTORS = [
{ id: 'overflow', run: detectOverflow },
{ id: 'clipped', run: detectClipped },
{ id: 'offscreen-text', run: detectOffscreenText },
{ id: 'unasked-clamp', run: detectUnaskedClamp },
{ id: 'clipped-ring', run: detectClippedRing, focus: true },
{ id: 'contrast', run: detectContrast },
{ id: 'raw-key', run: detectRawKey },
{ id: 'direction-leak', run: detectDirectionLeak },
{ id: 'tracked-script', run: detectTrackedScript },
{ id: 'orphan', run: detectOrphan },
]
/** The source Playwright evaluates: the helpers, then each chosen detector. */
export function pageScript(options, ids) {
const chosen = DETECTORS.filter((d) => ids.includes(d.id))
return `(() => {
const h = (${pageHelpers})(${JSON.stringify(options)})
const run = [${chosen.map((d) => `[${JSON.stringify(d.id)}, ${d.run}]`).join(',\n')}]
const findings = []
const broken = []
for (const [detector, detect] of run) {
try {
for (const finding of detect(h)) findings.push({ detector, ...finding })
} catch (error) {
broken.push({ detector, error: String((error && error.message) || error) })
}
}
return { findings, broken, counts: h.counts() }
})()`
}
/**
* Every detector over the page as it stands, then the ring walk: one Tab at a
* time, measuring whatever has focus, until focus comes back round or the
* page runs out of stops.
*/
export async function measurePage(page, options, ids) {
const still = ids.filter((id) => !DETECTORS.find((d) => d.id === id)?.focus)
const result = await page.evaluate(pageScript(options, still))
result.counts.focusStops = 0
if (!ids.includes('clipped-ring')) return result
const step = `(() => {
const el = document.activeElement
if (!el || el === document.body || el === document.documentElement) return { done: true }
window.__screenStops ??= new Set()
if (window.__screenStops.has(el)) return { done: true }
window.__screenStops.add(el)
const h = (${pageHelpers})(${JSON.stringify(options)})
return { done: false, findings: (${detectClippedRing})(h) }
})()`
await page.evaluate(() => {
window.__screenStops = new Set()
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
})
const limit = options.maxFocusStops ?? 150
for (let i = 0; i < limit; i++) {
await page.keyboard.press('Tab')
let stop
try {
stop = await page.evaluate(step)
} catch (error) {
result.broken.push({ detector: 'clipped-ring', error: String(error?.message ?? error) })
break
}
if (stop.done) break
result.counts.focusStops++
for (const finding of stop.findings) result.findings.push({ detector: 'clipped-ring', ...finding })
if (i === limit - 1) result.counts.focusCapped = 1
}
await page.evaluate(() => window.scrollTo(0, 0))
return result
}
// ------------------------------------------------------------------ NODE ---
/**
* Flags. Booleans and values are pulled out separately on purpose: one helper
* that guessed, by taking the next argument unless it began with a dash, once
* ate a path and scanned the wrong files while looking entirely normal.
* Returns { error } rather than exiting, so it can be tested.
*/
export function parseArgs(argv, config = CONFIG) {
const args = [...argv]
const flag = (name) => {
const i = args.indexOf(name)
if (i === -1) return false
args.splice(i, 1)
return true
}
const value = (name) => {
const i = args.indexOf(name)
if (i === -1) return null
const v = args[i + 1]
if (v === undefined || v.startsWith('--')) return { error: `${name} needs a value.` }
args.splice(i, 2)
return v
}
const list = (v) => v.split(',').map((s) => s.trim()).filter(Boolean)
const out = {
check: flag('--check'),
json: flag('--json'),
rtl: flag('--rtl') || config.rtl.enabled,
baseUrl: config.baseUrl,
routes: config.routes,
widths: config.widths,
modes: config.modes,
only: config.detectors,
}
for (const [name, key] of [['--base-url', 'baseUrl'], ['--routes', 'routes'], ['--widths', 'widths'], ['--modes', 'modes'], ['--only', 'only']]) {
const v = value(name)
if (v && typeof v === 'object') return v
if (v === null) continue
out[key] = key === 'baseUrl' ? v : list(v)
}
const unknown = args.find((a) => a.startsWith('--'))
if (unknown) return { error: `unknown flag ${unknown}.` }
// Bare arguments are routes, and replace the configured list.
if (args.length) out.routes = args
out.baseUrl = out.baseUrl.replace(/\/+$/, '')
out.routes = out.routes.map((r) => (/^https?:\/\//.test(r) || r.startsWith('/') ? r : `/${r}`))
out.widths = out.widths.map(Number)
if (out.widths.some((w) => !Number.isInteger(w) || w < 200)) return { error: `--widths takes whole numbers of CSS pixels, 200 or more.` }
const badMode = out.modes.find((m) => m !== 'light' && m !== 'dark')
if (badMode) return { error: `unknown mode "${badMode}". Modes are light and dark.` }
const badDetector = out.only.find((d) => !DETECTORS.some((x) => x.id === d))
if (badDetector) return { error: `unknown detector "${badDetector}". Known: ${DETECTORS.map((d) => d.id).join(', ')}.` }
return out
}
/** Every key path in a locale file, flattened, so raw-key matches by identity. */
export function flattenKeys(node, prefix = '', out = []) {
for (const [key, value] of Object.entries(node ?? {})) {
const path = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) flattenKeys(value, path, out)
else out.push(path)
}
return out
}
const fail = (message) => {
console.error(`check-screens: ${message}`)
process.exit(2)
}
/** Refuse to run on a malformed acceptance: a finding is something on the
* screen, and this is the check itself being broken. */
function validateAccepted() {
for (const [i, entry] of ACCEPTED.entries()) {
const name = `ACCEPTED[${i}]`
if (!entry?.reason || !entry?.until || !entry?.detector || !entry?.match) {
fail(`${name} needs a detector, a match, a reason and an until date. "Nobody has got round to it" is not a reason.`)
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.until)) fail(`${name} has until: "${entry.until}". It has to be YYYY-MM-DD. Anything else never expires.`)
}
}
const acceptedFor = (route, finding) =>
ACCEPTED.find(
(a) =>
(a.route === '*' || a.route === route) &&
a.detector === finding.detector &&
[finding.element, finding.message, finding.key ?? ''].some((s) => s.includes(a.match)),
)
async function main() {
const opts = parseArgs(process.argv.slice(2))
if (opts.error) fail(opts.error)
validateAccepted()
if (opts.routes.length === 0) {
fail('no routes to visit. Nothing was checked. List them in CONFIG.routes or pass --routes /,/about.')
}
// Playwright is the consumer's dependency, not this file's, so its absence
// is a state to report rather than a stack trace.
let chromium
try {
;({ chromium } = await import('playwright'))
} catch {
fail('Playwright is not installed, so no screen was looked at.\n pnpm add -D playwright && pnpm exec playwright install chromium')
}
// Is anything listening? Any answer counts, even a 404: that is the app
// saying something. A refused connection is nothing to measure.
try {
await fetch(opts.baseUrl, { signal: AbortSignal.timeout(10000), redirect: 'manual' })
} catch (error) {
fail(`nothing answered at ${opts.baseUrl} (${error?.cause?.code ?? error?.message ?? error}).\n Start the app, or pass --base-url.`)
}
let browser
try {
browser = await chromium.launch()
} catch (error) {
const first = String(error?.message ?? error).split('\n')[0]
fail(`could not start Chromium: ${first}\n pnpm exec playwright install chromium`)
}
const localeFile = CONFIG.i18n.localeFiles.find((f) => existsSync(resolve(f)))
let keys = []
if (localeFile) {
try {
keys = flattenKeys(JSON.parse(readFileSync(resolve(localeFile), 'utf8')))
} catch (error) {
fail(`${localeFile} is not JSON this can read: ${error.message}`)
}
}
const options = {
ignore: CONFIG.ignore,
contrast: CONFIG.contrast,
clampMarker: CONFIG.clampMarker,
keyPattern: CONFIG.i18n.keyPattern,
notKeys: CONFIG.i18n.notKeys,
keys,
maxFocusStops: CONFIG.maxFocusStops,
}
const renders = []
for (const width of opts.widths) {
for (const mode of opts.modes) {
renders.push({ width, mode, rtl: false })
if (opts.rtl) renders.push({ width, mode, rtl: true })
}
}
const label = (r) => `${r.width} ${r.mode}${r.rtl ? ' rtl' : ''}`
const urlFor = (route, render) => {
let path = route
if (render.rtl && CONFIG.rtl.routePrefix && !/^https?:/.test(path)) path = `${CONFIG.rtl.routePrefix}${path === '/' ? '' : path}` || '/'
const url = new URL(path, `${opts.baseUrl}/`)
if (render.rtl && CONFIG.rtl.query) for (const [k, v] of new URLSearchParams(CONFIG.rtl.query)) url.searchParams.set(k, v)
return url.href
}
const rtlByAttribute = !CONFIG.rtl.routePrefix && !CONFIG.rtl.query
const rows = []
const unmeasured = []
const broken = []
const counts = {}
for (const render of renders) {
const context = await browser.newContext({
viewport: { width: render.width, height: CONFIG.height },
colorScheme: render.mode,
reducedMotion: 'reduce',
})
const theme = CONFIG.theme
if (theme.storageKey) {
await context.addInitScript(([key, value]) => {
try {
localStorage.setItem(key, value)
} catch {}
}, [theme.storageKey, theme.values[render.mode]])
}
for (const route of opts.routes) {
const url = urlFor(route, render)
let result = null
let why = ''
// ONE PAGE MUST NOT KILL THE RUN. A dev server that recompiles mid-visit
// destroys the page under an evaluate, and that once took out a
// nine-minute sweep. One retry, then a line in the report naming what
// could not be read: a run that quietly skipped a screen is worse than
// one that says which.
for (let attempt = 0; attempt < 2 && result === null; attempt++) {
const page = await context.newPage()
try {
const response = await page.goto(url, { waitUntil: 'load', timeout: CONFIG.timeoutMs })
if (response && response.status() >= 400) {
why = `answered ${response.status()}`
break
}
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
await page.evaluate(
async ({ theme, mode, rtl }) => {
await document.fonts?.ready
const html = document.documentElement
if (theme.strategy === 'attribute') html.setAttribute(theme.attribute, theme.values[mode])
if (theme.strategy === 'class') {
for (const v of Object.values(theme.values)) if (v) html.classList.remove(v)
if (theme.values[mode]) html.classList.add(theme.values[mode])
}
if (rtl) html.setAttribute('dir', 'rtl')
window.scrollTo(0, 0)
},
{ theme, mode: render.mode, rtl: render.rtl && rtlByAttribute },
)
// Measure the end state, not a frame of a transition into it.
await page.addStyleTag({
content:
'*, *::before, *::after { transition-duration: 0s !important; transition-delay: 0s !important; animation-duration: 0s !important; animation-delay: 0s !important; scroll-behavior: auto !important }',
})
await page.waitForTimeout(CONFIG.settleMs)
result = await measurePage(page, options, opts.only)
} catch (error) {
why = String(error?.message ?? error).split('\n')[0]
await new Promise((r) => setTimeout(r, 1500))
} finally {
await page.close()
}
}
if (!result) {
unmeasured.push({ route, render: label(render), why })
continue
}
for (const b of result.broken) broken.push({ route, render: label(render), ...b })
for (const [k, v] of Object.entries(result.counts)) counts[k] = (counts[k] ?? 0) + v
for (const finding of result.findings) rows.push({ route, render: label(render), ...finding })
}
await context.close()
}
await browser.close()
// -------------------------------------------------------------- REPORT ---
// One finding across renders is one finding: the same box overflowing at 390
// light and 390 dark is one fix, listed with where it showed.
const today = new Date().toISOString().slice(0, 10)
const groups = new Map()
for (const row of rows) {
const key = `${row.route}\u0000${row.key ?? `${row.detector}|${row.element}|${row.message.replace(/\d+(\.\d+)?/g, '#')}`}`
if (!groups.has(key)) groups.set(key, { ...row, renders: new Set(), examples: new Set() })
const g = groups.get(key)
g.renders.add(row.render)
if (row.example && g.examples.size < 3) g.examples.add(row.example)
}
const all = [...groups.values()].map((g) => ({ ...g, renders: [...g.renders], examples: [...g.examples] }))
const accepted = []
const findings = []
const soft = []
for (const f of all) {
const entry = acceptedFor(f.route, f)
if (entry && today <= entry.until) accepted.push({ ...f, entry })
else if (CONFIG.soft.includes(f.detector)) soft.push({ ...f, expired: entry })
else findings.push({ ...f, expired: entry })
}
const measured = renders.length * opts.routes.length - unmeasured.length
const incomplete = unmeasured.length > 0 || broken.length > 0
const code = measured === 0 ? 2 : opts.check && incomplete ? 2 : opts.check && findings.length ? 1 : 0
// Blind spots are named, not skipped. A detector that matched nothing looks
// exactly like one that found nothing, so the run says which is which.
const blind = []
if (!opts.rtl && opts.only.includes('direction-leak')) {
blind.push('direction-leak: no right-to-left render. Set CONFIG.rtl.enabled, or pass --rtl.')
}
if (opts.only.includes('unasked-clamp') && !counts['clamp-marked']) {
blind.push(`unasked-clamp: no element carries ${CONFIG.clampMarker}, so it checked nothing. Have your text component mark its own default clamps.`)
}
if (opts.only.includes('raw-key') && !localeFile) {
blind.push(`raw-key: no locale file at ${CONFIG.i18n.localeFiles.join(', ')}, so keys were matched by shape only.`)
}
if (opts.only.includes('contrast') && counts['contrast-unmeasured']) {
blind.push(
`contrast: skipped ${counts['contrast-unmeasured']} text(s) across ${measured} page render(s), on a gradient, an image, a frosted ` +
'panel, a blend or a filter. Their canvas is not in the ancestor chain, so they were not guessed at. Look at those.',
)
}
if (opts.only.includes('clipped-ring')) {
blind.push(
counts.focusStops
? `clipped-ring: walked ${counts.focusStops} focus stop(s)${counts.focusCapped ? `, stopping at ${CONFIG.maxFocusStops} on some pages` : ''}.`
: 'clipped-ring: Tab reached nothing focusable, so no ring was measured.',
)
}
if (opts.json) {
console.log(JSON.stringify({ baseUrl: opts.baseUrl, routes: opts.routes, renders: renders.map(label), measured, unmeasured, broken, findings, soft, accepted, blind }, null, 2))
process.exit(code)
}
const matrix = `${opts.widths.join(' and ')}px, ${opts.modes.join(' and ')}${opts.rtl ? ', ltr and rtl' : ''}`
const print = (list) => {
let route = null
for (const f of list.sort((a, b) => opts.routes.indexOf(a.route) - opts.routes.indexOf(b.route) || DETECTORS.findIndex((d) => d.id === a.detector) - DETECTORS.findIndex((d) => d.id === b.detector))) {
if (f.route !== route) {
route = f.route
console.log(`\n ${route}`)
}
console.log(` ${f.detector.padEnd(15)} at ${f.renders.join(', ')}`)
console.log(` ${f.element} ${f.message}`)
if (f.examples.length) console.log(` e.g. ${f.examples.join('; ')}`)
if (f.expired) console.log(` ACCEPTED UNTIL ${f.expired.until}, WHICH HAS PASSED. Fix it, or agree a new date and write down what changed.`)
console.log(` fix: ${f.fix}`)
}
}
if (findings.length) {
const on = new Set(findings.map((f) => f.route)).size
console.log(`\ncheck-screens: ${findings.length} finding(s) on ${on} of ${opts.routes.length} route(s), at ${matrix}, against ${opts.baseUrl}`)
print(findings)
} else {
console.log(`check-screens: clean. ${measured} page render(s) of ${opts.routes.length} route(s), at ${matrix}, against ${opts.baseUrl}.`)
}
if (soft.length) {
console.log(`\n Softer, and they do not fail --check: ${soft.length} orphan word(s).`)
print(soft)
}
if (accepted.length) {
console.log('\n Accepted, each with a reason and a date:')
for (const a of accepted) {
console.log(` [known] ${a.route} ${a.detector}, until ${a.entry.until}`)
console.log(` ${a.element} ${a.message}`)
console.log(` ${a.entry.reason}`)
}
}
const stale = ACCEPTED.filter((entry) => !all.some((f) => acceptedFor(f.route, f) === entry))
for (const entry of stale) {
console.log(`\n [stale] ACCEPTED ${entry.detector} "${entry.match}" matched nothing this run. Delete it, so the list keeps meaning what it says.`)
}
if (unmeasured.length || broken.length) {
console.log('\n NOT MEASURED. These are not passes:')
for (const u of unmeasured) console.log(` ${u.route} at ${u.render}: ${u.why || 'could not be read'}`)
for (const b of broken) console.log(` ${b.route} at ${b.render}: ${b.detector} crashed: ${b.error}`)
}
if (blind.length) {
console.log('\n Not checked, or checked in part:')
for (const line of blind) console.log(` ${line}`)
}
console.log()
process.exit(code)
}
// Run only when executed, so the tests can import the detectors. Compared by
// real path: macOS's temp directory is a symlink, and a plain comparison there
// skipped main and exited 0 having checked nothing.
const invoked = (() => {
try {
return process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
} catch {
return false
}
})()
if (invoked) await main()
How to tell it works: Start the app and run it with no arguments: it prints each route with its findings, or says it is clean and names what it could not check. Put a fixed-width box wider than 390px on a page and it names the box and says the page scrolls sideways. Stop the app and it exits 2, saying nothing answered, rather than reporting a pass.