boxy-layout
v0.1.0
Published
Spatial layout linter for Playwright with verified CSS causation analysis
Maintainers
Readme
Boxy
Drop-in layout linter for Playwright. Add a few lines to your existing tests to catch clipping, overflow, and CSS regressions automatically.
Not pixel diffing. Boxy builds a spatial model of your rendered page and detects broken layout patterns. When something breaks, it tells you which CSS property changed on which element caused it — verified by re-rendering with each change in isolation.
The Problem
The worst UI bugs are the ones where:
- Someone adds
overflow: hiddento a container and now dropdowns are clipped - A modal gets
overflow: hiddenand select menus inside it are cut off - A sidebar collapses to 0px and all nav items disappear
- An element gets
visibility: hiddenoropacity: 0and nobody notices - A z-index change buries a popover behind another element
These bugs are invisible to pixel diffing because they only appear after specific interactions (open menu, scroll, open another menu), depend on content length or viewport size, and the baseline screenshot was taken in a different state.
Install
npm install -D boxy-layoutQuick Start
Add capture() calls to your existing Playwright tests after interactions:
import { createBoxy } from 'boxy-layout';
const boxy = createBoxy();
test('users page', async ({ page }) => {
await page.goto('/users');
await boxy.capture(page, { name: 'users-loaded' });
// Open a dropdown — does it get clipped?
await page.click('[data-testid="filter-status"]');
await boxy.capture(page, { name: 'filter-open' });
// Open action menu on last row — does it overflow the table?
await page.click('[data-testid="action-btn"]');
await boxy.capture(page, {
name: 'action-menu',
scope: '[data-testid="users-table"]',
});
});
afterAll(() => {
const exitCode = boxy.report();
boxy.writeHTMLReport();
if (exitCode) throw new Error('boxy found layout errors — see .boxy/report.html');
});Throw rather than process.exit(exitCode): calling process.exit inside afterAll truncates Playwright's own reporting and teardown, so you lose the test report. Throwing fails the suite cleanly and lets Playwright finish.
Parallel workers
Under parallel Playwright workers (--workers=N), Boxy writes a per-worker HTML report (report-w<idx>.html) so workers don't overwrite each other, and baseline/screenshot writes are atomic with a first-writer-wins auto-save. Give every capture() a name that is unique across the whole suite — workers share one baseline directory, so a duplicate name from two workers compares against the same baseline.
How It Works
First run (no baseline)
Baselines are auto-saved on first run, but missing baselines fail by default so CI catches an absent trust anchor. For an intentional setup run:
LAYOUT_INIT=true npx playwright testOn the first run, Boxy saves the current layout as the baseline and runs lint checks only (no regression comparison). Without LAYOUT_INIT=true or acceptNewBaselines: true, that baseline-created notice is reported as a failure.
Subsequent runs
On subsequent runs, Boxy compares against the saved baseline and reports both lint issues and regressions.
Updating baselines after intentional changes
When you intentionally change the layout, update baselines:
LAYOUT_UPDATE=true npx playwright testOr per-capture:
await boxy.capture(page, { name: 'redesigned-header', update: true });Resetting baselines
boxy.resetBaseline('dashboard'); // delete one baseline
boxy.resetAllBaselines(); // clear all baselines and current snapshotsLint checks (no baseline needed)
| Check | What it catches |
|-------|----------------|
| Clipping | Element extends beyond parent with overflow: hidden/auto/scroll |
| Overlap | Positioned elements overlapping where high z-index is clipped |
| Collapsed | Element has near-zero width/height but contains content |
| Off-screen | Positioned element outside viewport bounds |
Regression checks (with baseline)
| Check | What it catches |
|-------|----------------|
| Spacing | Gap between siblings changed beyond threshold |
| Position | Element shifted significantly from baseline |
| Size | Element width/height changed >30% |
| Visibility | Element disappeared, became visibility:hidden, or opacity:0 |
| CSS diff | Shows which computed styles changed on which elements |
| Viewport guard | Viewport or scroll position differs from baseline — comparison is skipped with a single clear error instead of a wall of bogus position diffs |
Verified Causation Analysis
When a regression is detected, Boxy can prove exactly which CSS property caused it. With the page still in its broken state, each candidate change is reverted to its baseline value in isolation and the layout is re-measured: if reverting a property moves the layout back toward the baseline, it's a verified root cause; if nothing changes, it had no impact.
// After capturing a regression — keep the page in the same (broken) state
const step = await boxy.capture(page, { name: 'detail', scope: '[data-testid="panel"]' });
const result = await boxy.diagnoseCauses(page, 'detail');┌─────────────────────────────────────────────────────────┐
│ Causation Analysis │
└─────────────────────────────────────────────────────────┘
8 re-renders performed
┌ ROOT CAUSES (1 verified)
│ [data-testid="detail-body"]
│ maxHeight: none → 150px
│ → shifted 39 elements, resized 2 elements
└
┌ NO IMPACT (6 verified)
│ [data-testid="detail-header"]
│ zIndex: auto → 99
│ [data-testid="detail-close"]
│ maxWidth: none → 9999px
│ [data-testid="detail-body"]
│ overflow: auto → hidden
│ [data-testid="detail-body"]
│ overflowX: auto → hidden
│ [data-testid="detail-body"]
│ overflowY: auto → hidden
└7 CSS properties changed. Only 1 actually broke the layout. Boxy proves it.
Configuration
const boxy = createBoxy({
snapshotDir: '.boxy', // where to store baselines
update: false, // set true to overwrite baselines (or use LAYOUT_UPDATE=true)
allowMissingBaseline: true, // auto-save baseline on first run (set false to require existing baseline)
acceptNewBaselines: false, // set true for intentional setup runs (or use LAYOUT_INIT=true)
stabilize: true, // wait for fonts + layout to settle before each capture
config: {
spacingThreshold: 4, // px — spacing changes below this are ignored
positionThreshold: 20, // px — position shifts below this are ignored
sizeChangePercent: 30, // % — size changes below this are ignored
collapsedMinSize: 5, // px — elements smaller than this are flagged
ignore: [ // selectors to skip
'.scrollable-list',
'[data-testid="carousel"]',
],
},
});Output
Terminal
┌─────────────────────────────────────────────────────────┐
│ Layout Lint Results │
└─────────────────────────────────────────────────────────┘
✓ users-loaded
✓ filter-open
✗ action-menu (4 errors, 0 warnings)
┌ CLIPPING
│ ✗ Element clipped by parent overflow
│ [data-testid="action-menu"]
│ clipped by: [data-testid="users-table"]
│ hidden: bottom: 94px
└
┌ CSS CHANGES (caused layout impact)
│ [data-testid="users-table"]
│ overflow: visible → hidden
└HTML Report
Generated at .boxy/report.html with screenshots and issue details.
CI
Exit code 1 when errors are found:
- run: npx playwright testTesting
# Smoke test (good version should pass clean)
npm test
# Broken version should catch clipping errors
npm run test:broken
# Interaction-driven scenario tests
npm run test:scenarios
npm run test:scenarios:broken
# Mutation suite: inject 9 realistic CSS bugs, verify all are caught
npm run test:mutationsCurrent mutation detection rate: 9/9 (100%)
| Mutation | What it simulates |
|---|---|
| table-overflow-hidden | overflow:hidden added to scrollable table container |
| modal-overflow-hidden | Modal clips select dropdowns inside forms |
| sidebar-collapse-to-zero | Collapsed sidebar width set to 0 instead of icon-width |
| detail-panel-overflow-hidden | Detail panel body loses scroll, clips long content |
| filter-popover-under-table | Filter popover z-index lowered, goes behind sticky header |
| notif-dropdown-off-screen | Notification dropdown positioned with wrong offset |
| row-dropdown-opens-down | Action menu opens downward instead of upward on last rows |
| nav-items-visibility-hidden | Nav items set to visibility:hidden |
| pagination-opacity-zero | Pagination faded to opacity:0 |
How It Works
- Captures the bounding box, computed styles, z-index, overflow, visibility, opacity, and sibling spacing for every visible element — after waiting for fonts to load and layout to stop changing, so animations and late font swaps can't poison the model
- Lints the spatial model for broken patterns (clipping, overlap, collapse)
- Compares against baseline (if available) for regressions (spacing, position, size, visibility)
- Diffs computed CSS between baseline and current, classifying changes as effective (caused spatial impact) or inert (no layout difference)
- Verifies causation (optional) to identify which specific CSS property caused the breakage
Why Not Percy / Chromatic?
| | Pixel diffing | Boxy |
|---|---|---|
| False positives | High (anti-aliasing, fonts, rendering) | Low (spatial analysis) |
| Explains the bug | "These pixels changed" | "Dropdown clipped because overflow: auto → hidden" |
| Identifies root cause | No | Yes — pinpoints the CSS property that caused it |
| Needs cloud infra | Yes | No (runs locally) |
| Needs baseline | Always | Only for regression checks |
| Post-interaction testing | Only if you screenshot every state | Captures spatial model after any interaction |
License
MIT
