prelight
A layout linter for translated UI. Verified against Chromium, WebKit, and Firefox.
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.
noOverflow— natural width fits the slot.maxLines— wraps to at most N lines.minLines— fills at least N lines.lines— takes exactly N lines.singleLine— stays on one line AND fits.noTruncation— never ellipsized.
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.