check-screens.mjs

Rendered screen checks

Tier 3designengineeringWarns only

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.

CHECK-SCREENS.MJSPRINTS, DOES NOT BLOCK RUNNING APP ROUTES CLEAN SIDEWAYS SCROLL CLIPPED FOCUS RING RAW KEY ON SCREEN
Add this to your project
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
check-screens.mjsscripts/check-screens.mjs
1615 lines
#!/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.

Included in