prelight

A layout linter for translated UI. Verified against Chromium, WebKit, and Firefox.

98.81%
cross-engine agreement, 928-case corpus
0.024ms/cell
warm-path mean, 50-run
23×
faster than Playwright on local dev
8.6× on shared Ubuntu CI

The bug everybody has

A designer picks a 120-pixel-wide button. The engineer types width: 120px. The tests pass. The app ships. A user in Berlin gets Rechtsschutzversicherungsgesellschaften crammed into that button and files a bug.

This is never caught in dev because the dev reads English. It's rarely caught in CI because rendering text in a browser is slow and flaky. Internationalization teams have been live-patching it for twenty years.

1. The button that fits in English and breaks in German

The failing case that every product team on Earth has lived through. Same <Button>. Same 120-pixel slot. Four languages. Watch what the German string does.

// demos/failing-german-button/Button.test.tsx
import '@prelight/vitest'
import { verifyComponent } from '@prelight/react'
import { Button } from './Button'

const labels = {
  en: 'Get coverage',
  de: 'Rechtsschutzversicherungsgesellschaften',
  ar: 'شركات التأمين',
  ja: '法的保護保険会社',
}

test('"Get coverage" button fits at every language', () => {
  const result = verifyComponent({
    element: (lang) => <Button label={labels[lang]} />,
    languages: ['en', 'de', 'ar', 'ja'],
    font: '16px sans-serif',
    maxWidth: 120,
    lineHeight: 20,
    constraints: { maxLines: 1, noOverflow: true },
  })
  expect(result.ok).toBe(true)
})

This is the real output. Copied from the terminal, not paraphrased:

Prelight: 2 failures across 4 cells.

  de  scale=1  width=120px
    - noOverflow: text overflows its slot at de scale=1 (width 120px):
      single-line text would be 213.0px wide, 93.0px over. Wraps to 2 lines.
    - maxLines: text wraps to 2 lines but may not exceed 1 at de scale=1.

Notice the pixels. Not "it broke." Not a screenshot diff. A measured number of pixels over the edge, the exact language it happened in, and the exact scale — so the fix is obvious.

2. A config file, a CLI, and your whole design system under verification

Seven components, four languages, three accessibility scales — 84 cells, verified from a single config file on every PR.

# prelight.config.tsx (abridged)
import type { PrelightConfig } from '@prelight/cli'
import { Button } from './components/Button'
import { NavLink } from './components/NavLink'
import { StatusBadge } from './components/StatusBadge'
import { Toast } from './components/Toast'
import { labels, LANGS } from './labels'

const matrix = { languages: LANGS, fontScales: [1, 1.25, 1.5] }

export default {
  tests: [
    { name: 'Button: Save', element: (l) => <Button label={labels[l].save}/>,
      font: '16px sans-serif', maxWidth: 120, lineHeight: 20,
      constraints: { maxLines: 1, noOverflow: true }, ...matrix },
    { name: 'NavLink: Settings', element: (l) => <NavLink label={labels[l].settings_nav}/>,
      font: '15px sans-serif', maxWidth: 96, lineHeight: 20,
      constraints: { maxLines: 1, noOverflow: true }, ...matrix },
    { name: 'StatusBadge: Refunded', element: (l) => <StatusBadge label={labels[l].status_refunded}/>,
      font: '13px sans-serif', maxWidth: 80, lineHeight: 16,
      constraints: { maxLines: 1, noOverflow: true }, ...matrix },
    // … three more …
  ],
} satisfies PrelightConfig

Run the CLI. Real, unedited terminal output from bun run prelight:

Prelight: 3 of 7 tests failed (84 cells in 32ms)

  [PASS] Button: Save (short copy)  (12 cells)
  [PASS] Button: New item (medium copy)  (12 cells)
  [FAIL] NavLink: Settings  (12 cells)
      · [noOverflow] ar @ scale=1.5, width=96px — single-line text
        would be 101.3px wide, 5.3px over. Wraps to 2 lines.
      · [maxLines]   ar @ scale=1.5, width=96px — wraps to 2 lines
        but may not exceed 1.
  [FAIL] NavLink: Pricing   (12 cells)
      · [noOverflow] de @ scale=1.5, width=96px — single-line text
        would be 111.5px wide, 15.5px over. Wraps to 2 lines.
  [PASS] StatusBadge: Paid  (12 cells)
  [FAIL] StatusBadge: Refunded (12 cells)
      · [noOverflow] ar @ scale=1,    width=80px — 14.7px over.
      · [noOverflow] ar @ scale=1.25, width=80px — 38.4px over.
      · [noOverflow] ar @ scale=1.5,  width=80px — 62.0px over.
      … 1 more failure(s)
  [PASS] Toast: Error message (may wrap)  (12 cells)

84 cells, under 32 ms. The exit code is 1, CI fails the PR, and the author sees the exact pixel overflow before asking a reviewer.

3. Faster than Playwright. By a lot, and it depends on your hardware.

The same 36-cell verification workload, compared against Playwright in its most favorable configuration: a single reused Chromium page, page.evaluate() returning numbers only, no screenshots. 50 iterations per side, warm path.

Two sets of numbers — the dev-hardware headline, and the shared-VM floor that our CI re-measures on every push. Both are reproducible from the same command.

Local dev hardware (Apple M-series, warm JIT):

Prelight (static, in-process):
  mean 0.88 ms  p50 0.81 ms  p95 1.64 ms  p99 1.84 ms
  per-cell mean 0.024 ms
  warmup 19.67 ms (one-time)

Playwright (DOM measurement):
  mean 20.35 ms  p50 19.96 ms  p95 25.02 ms  p99 27.62 ms
  per-cell mean 0.57 ms
  launch cost 243 ms (one-time, excluded from samples)

Prelight is 23.2× faster on the warm path (mean-vs-mean).
End-to-end, including Chromium launch, Prelight finishes 20× faster.

Shared Ubuntu CI runner (Azure VM, noisy neighbours):

Prelight:    mean 4.70 ms   p50 4.91   p95 7.59   p99 9.22
Playwright:  mean 40.35 ms  p50 39.83  p95 48.88  p99 51.28

Prelight is 8.58× faster on the warm path, 50 iterations.

Same code, same command, same distribution shape — the absolute numbers rise ~5× on the shared VM but the order of magnitude holds. Our ubuntu-full CI workflow enforces a 7× minimum floor on every push, so a regression that halves the speedup fails the build before it can ship.

Run it yourself: cd demos/speed-comparison && npx tsx bench.ts --iterations=50. Every number here is checked into RESULTS.md, dated, append-only.

What makes this possible

Prelight composes with @chenglou/pretext, a DOM-free text measurement library that uses the font engine (canvas.measureText) directly. Prelight adds the layer above: languages, scales, predicates, failure reporting, and React extraction.

We bundle Inter Variable v4.1 so measurements are deterministic across machines, plus a 611 KB NotoEmoji-subset.ttf (GSUB-closed, monochrome outline from Noto-COLRv1) so emoji measurement works out of the box. Against real Chromium, WebKit, and Firefox on a 928-case multilingual corpus (7 languages + a 51-string ZWJ/skin-tone/flag stress set) we agree 98.81% / 99.03% / 98.60% overall. Emoji reaches 99.75% on all three engines after v0.3 H6c shipped the bundled font and per-grapheme correctEmojiLayout. Every disagreement is counted in FINDINGS.md and gated with per-engine × per-language floors in CI.

We also publish our own adversarial review: REVIEW-v0.3.0.md. One critical finding, five important, three discussion items — documented against the library's own "no public claim without evidence" invariant. Worth a read before dropping prelight into a CI path.

Predicates

Every predicate is a pure boolean over one measured cell.

Install

bun add -D @prelight/core @prelight/react @prelight/vitest
# or
npm install --save-dev @prelight/core @prelight/react @prelight/vitest

Honest about scope

v0.1 verifies what text does. It does not solve margin collapse, grid intrinsic sizing, or CSS cascade resolution. Those are on the roadmap (ROADMAP.md), not in this release.

v0.1 ships with a ground-truth harness that diffs every corpus case against real Chromium rendering. Every release must pass it.

Read more