Keep your layout crisp at every width.
starchy finds every screen width where your page's layout changes, and flags the changes that are bugs.
npx starchy https://example.comWebsite: starchy.dev
Most responsive testing screenshots a handful of fixed breakpoints, such as 375, 768 and 1440, so a bug that only appears at 912px goes unnoticed. starchy sweeps the whole range and records where the layout shifts. You get the exact pixel, the element, and whether the change was on purpose.
npx playwright install chromium # once
npx starchy https://example.comThis is the output for the test page in this repo, npx starchy test/fixture.html:
starchy file:///path/to/starchy/test/fixture.html
widths 1920→320px, heights 900px
media-query breakpoints found: 1024, 768
height 900px
1049px problem
1024px breakpoint
900px problem
847px fluid
768px breakpoint
685px problem
…
problems
684→320px Horizontal scroll div.card ×2: pushes the page 366px wider than the screen
css: .pricing .card { flex: 1 0 210px } (fixture.html:47)
899→320px Overlap div in div.stats ↔ div in div.stats: overlap 130×82px
css: .stats div + div { margin-left: calc(22vw - 200px) } (fixture.html:40)
845→320px Wrapped link a.book: "Book now" wraps onto 2 lines
1920→320px Tiny text span in div.tags ×3: font-size 11px (min 12px)
css: .tags span { font-size: 11px } (fixture.html:52)
…
16 layout changes · 7 problems (9 elements) · 1.2s
report → starchy-report/report.html
not checked: what fixed bars cover while scrolling, whether an element (other than a submit button) that hides below some width should stay hidden, what images show, color and contrast.
look: starchy-report/overview/ has the page at 1440, 1024, 768, 390px (fixture-390-1.jpg …). Open a few: starchy can't see everything.
Each problem is printed with its width range, the element that causes it, and
the CSS rule that most likely causes it, with its file and line. The same problem
on repeated elements, or over several ranges, is printed once. The last line
lists what the sweep cannot see, so "0 problems" is not the same as
"the page is fine". For those things, overview/ has screenshots of every page
at 1440, 1024, 768 and 390px, cut into tiles of at most 1500px high so the text
stays readable. starchy-report/report.html has the same list, plus before
and after screenshots of every layout change.
- Load the page at a max width (default 1920px) and fingerprint the layout:
for every container, how its children sit in rows (for example
3+3+1), which elements are visible, and any problems. - Step down 8px at a time, and also stop on both sides of every
@mediabreakpoint. When the fingerprint changes, binary-search to the exact pixel. - Take before/after screenshots with the changed elements outlined, and
classify the change:
- Media query: the width matches a
@mediabreakpoint in your CSS, so the change is expected. - Fluid change: the layout changed with no media query. It came from flex-wrap, auto-fit, or content size, so check that it's intended.
- Problem: something broke at this width.
- Media query: the width matches a
- Continue down to the min width (default 320px), once for each viewport height you list. On a machine with spare cores, starchy loads a few copies of the page and sweeps one slice of the range on each. Where two slices meet, both copies must give the same fingerprint; if they don't (random or animated content), starchy sweeps that page again on one copy.
- Write
report.htmland afingerprint.jsonyou can compare against later.
| Problem | What it means |
|---|---|
| Horizontal scroll | The page is wider than the screen. starchy names the element causing it by briefly hiding each candidate and checking whether the scroll width shrinks. |
| Overlap | In-flow sibling elements overlap each other. |
| Escapes container | An element sticks out of its parent's box. |
| Clipped text | Text is cut off by overflow: hidden. |
| Text overflows | Text runs out of its box: usually a long word, URL or email address, or white-space: nowrap. |
| Covered text | An absolutely positioned box (a badge, ribbon or label) sits on top of another element's text, or a bar fixed to the top of the screen covers text that scrolling can never reveal. Boxes that draw nothing, such as a transparent link stretched over a card, are skipped. |
| Text cut off | Text runs past the bottom of a box with a fixed height and overflow: hidden. Images cropped this way, and line-clamp, are fine. |
| Hidden submit button | A visible form has no visible submit button at some width. |
| Stretched image | A photo is drawn at a different shape than it has, with no object-fit. SVGs keep their shape and are skipped. |
| Wrapped link | A link or button label of 5 words or fewer breaks onto 2+ lines. Only a wrap inside one run of text counts, so lines made by <br> or by separate elements are not flagged. Links inside running text (an inline link with other text around it) and links inside headings are skipped. |
| Hidden in a range | An element hides in a middle range of widths but shows on both sides of it, for example a header button hidden to make room for the nav while the phone layout shows it again. |
| Small-range layout | A container's rows, or an element's visibility, hold for only 5px or fewer between two changes. This is usually a media-query off-by-one: min-width: 769px next to max-width: 767px leaves 768px with neither rule. |
| Tiny text | Text is below --min-font (default 12px). |
With these problems the layout works, but the page is harder to read or use.
The thresholds come from typography and accessibility rules. --no-opinions
skips them, for example in CI with --fail-on problems.
| Problem | What it means |
|---|---|
| Text at screen edge | Text sits less than 8px from the left or right edge of the screen, usually because a container lost its padding. Text in a box that scrolls sideways (a carousel) is skipped. |
| Line length | A paragraph or list item of 3 or more lines runs past 90 characters per line, or holds fewer than 25. Lines set by <br>, such as an address, are skipped. |
| Input zooms on iOS | Below 1024px, a text field, select or textarea has text under 16px, so iOS Safari zooms in when it gets focus. |
| Small tap target | A link, button or form control is smaller than --min-target (default 24px, from WCAG 2.5.8) and another target is too close. As in WCAG, links inside running text pass, and a small target passes when a circle of that size around its centre touches no other target. |
| Heading orphan | A heading of --orphan-words or more words (default 4) has one word alone on its last line. A <br> before that word is a chosen break and is not flagged, and neither is a last word that cannot fit on one line with the word before it, because no wrap can fix that. text-wrap: balance or pretty usually fixes it. |
Repeated problems are grouped: seven identical labels that all have tiny text
show as one row, span.tag ×7. The summary line counts both, for example
7 problems (9 elements).
Run it with npx, as above, or install it globally:
npm install -g starchy
npx playwright install chromiumstarchy needs Node 18 or later.
starchy https://example.com
starchy example.com # https:// is assumed (http:// for localhost)
starchy ./index.html --min 360 --heights 900,600
starchy example.com example.com/about # several pages, one report
starchy example.com/sitemap.xml # every page in the sitemap
starchy *.html # every page in this folderPass several URLs or files, or a sitemap (a URL or file that ends in .xml).
starchy follows sitemap index files. All pages go into one report.
A sitemap usually lists production URLs. To check those pages on a dev server
instead, add --origin:
starchy localhost:3000/sitemap.xml --origin http://localhost:3000Without --origin, starchy warns when a sitemap lists pages on another origin.
- A problem found on more than one page (usually a shared header or footer) is listed once, under "On more than one page".
- A page that fails to load (HTTP 400 or higher, or a load error) is listed under "Not checked", and the other pages still run.
- A page that redirects prints
warning: redirected to <url>, and the report says so.
| Option | Default | |
|---|---|---|
--max / --min |
1920 / 320 | Width range to sweep |
--step |
8 | Coarse step before bisecting. Lower is slower but more thorough. |
--heights |
900 | Comma-separated viewport heights, each swept separately |
--dpr |
1 | Device pixel ratio (affects images/hairlines, not layout) |
--min-font |
12 | Smallest acceptable text size in px |
--min-target |
24 | Smallest acceptable tap target in px |
--orphan-words |
4 | Flag a heading of this many words or more when its last line holds one word |
--ignore |
Selectors to skip, e.g. .carousel,.ad-slot |
|
--settle |
0 | Extra ms to wait after each resize, for JS-driven layouts |
--out |
starchy-report |
Output folder |
--baseline |
An earlier fingerprint.json to compare against |
|
--fail-on |
none |
problems or new makes starchy exit 1, for CI |
--no-screenshots |
Faster runs, report without before/after images | |
--no-opinions |
Skip the readability and usability checks: heading orphan, line length, text at screen edge, small tap target and input zooms on iOS. The other checks find layouts that are broken. | |
--no-overview |
Skip the overview/ screenshots of each page at four widths |
|
--problem-shots |
Add a screenshot of each problem at its first failing width, with the element outlined | |
--stress |
Double the text in repeated items (lists, cards, grids) before the sweep, to find layouts that only hold with short content. Button and nav labels stay as they are. | |
--storage-state |
A Playwright storage state file, for pages behind a login. See below. | |
--short |
Print only the problems and the summary, without the list of layout changes | |
--workers |
auto | Pages to sweep at once. The default comes from your CPU cores and memory: at most 8, or 4 for remote URLs. 1 sweeps on one page. |
--origin |
Check the pages a sitemap lists on this origin instead, for example http://localhost:3000. |
# once, on a known-good build:
starchy https://staging.example.com --out baseline
# on every pull request:
starchy https://staging.example.com --baseline baseline/fingerprint.json --fail-on newThe comparison looks at how elements sit in rows and boxes, so the font-rendering noise that makes screenshot diffs flaky does not affect it. The report lists new problems, fixed problems, and layout changes that appeared or disappeared.
Pages are matched by URL (example.com and example.com/ are the same page).
A single page compares against a single-page baseline even when the URLs
differ, so you can compare staging with local. Baselines from starchy 0.1.x
still work.
| Exit code | When |
|---|---|
| 0 | The run finished, and --fail-on did not trigger. |
| 1 | --fail-on problems found a problem, or --fail-on new found a new problem or a new layout change. Also: no page loaded, a sitemap could not be read, or an unexpected error occurred (for example, Chromium is not installed). |
| 2 | Bad arguments: an unknown option, an argument that is not a URL or an existing file, --min not below --max, or a missing --storage-state file. |
Run starchy again into the same --out folder after a fix, and it says what the
fix changed:
since last run: 4 problems fixed, 0 new
now hidden: a.order-btn hides below 541px
"now hidden" lists elements that hide at a width where they used to show. A problem that goes away because its element was hidden is often not a real fix. The comparison only runs when the sweep options are the same as last time.
Coding agents use starchy best when the project tells them about it. Add it to the project with a script:
npm install --save-dev starchy
npm pkg set 'scripts.check:layout=starchy *.html'For a site with a dev server, use the sitemap instead:
starchy localhost:3000/sitemap.xml --origin http://localhost:3000.
Then add this line to CLAUDE.md or AGENTS.md:
Run `npm run check:layout` after any CSS change. It checks every page at every width from 1920px to 320px. It doesn't judge images, crops or colors, so also look at the screenshots it saves in `starchy-report/overview/`.
The script lets an agent find starchy. The note tells it what starchy does not check, so it does not stop at "0 problems". With both, a one-line prompt is enough. Benchmark has the numbers.
Save a logged-in session once, then pass it to starchy:
npx playwright open --save-storage=auth.json https://example.com/login
# log in in the window that opens, then close it
starchy https://example.com/admin --storage-state auth.jsonauth.json holds a live session cookie. Never commit it, and delete it when
you are done. If a page redirects (for example to the login page because the
session expired), starchy prints a warning and the report says so.
- Change-and-revert inside one step can be missed. If the layout changes
and then changes back within 8px, the sweep won't see it, unless the change
starts or ends at an
@mediabreakpoint. Use--step 1for an exhaustive pass. - Container queries show up as "fluid". Only
@mediawidths are matched to breakpoints. - Worst offender first. When two elements both cause horizontal scroll, the widest one is named. The other surfaces once the first is fixed.
- Some content isn't inspected. Shadow DOM and iframes are skipped.
Auto-rotating carousels and other animated elements need
--ignore. - Inline text reflow is ignored on purpose. It changes at almost every pixel. Text is checked through the clipped-text, wrapped-link and tiny-text rules instead.
- Tiny text inside inline elements is missed. The tiny-text rule checks
block-level boxes, so a small
<span>or<small>inside a paragraph is not flagged. - Chromium only for now.
npm testUnit tests cover grouping, shared problems, media-query parsing and baseline
matching. End-to-end tests start a local server and run the real CLI against
test/fixture.html (planted bugs, listed in the comments at the top of the
file) and test/fixture-about.html. They cover every problem kind, --stress,
--problem-shots, sitemaps, skipped and redirected pages, --storage-state
and baselines. Reports land in test-report/, for example:
open test-report/basic/report.htmlcli.js CLI, sweep loop, bisection, screenshots
src/fingerprint.js runs in the page: layout units, row signatures, problem checks
src/analyze.js fingerprint hashing, media-query parsing, diff + classification
src/baseline.js compare against an earlier fingerprint.json
src/report.js HTML report
test/ fixtures and the test suite (node:test, no extra dependencies)
site/ starchy.dev, a static page
bench/ gives the same task to a coding agent with and without starchy. Each
run gets a fresh copy of a site with planted layout bugs, and the prompt is a
customer report: "parts of the site look broken on some phones and tablets".
A grader that does not use starchy checks every bug at every width from 320 to
1920px, checks that the design is kept, and counts regressions (problems the
original site did not have). Some bugs are held out: starchy cannot see them,
so they show whether agents still look at the page.
Six-page site, 25 bugs (3 held out), starchy 0.4.0, 6 runs per group:
| Model | starchy | Bugs fixed | Held-out fixed | Runs with a regression | Cost | Minutes |
|---|---|---|---|---|---|---|
| Sonnet 5.5 | yes | 24.0 / 25 | 13 / 18 | 0 / 6 | $0.17 | 1.7 |
| Sonnet 5.5 | no | 21.7 / 25 | 13 / 18 | 5 / 6 | $0.20 | 1.5 |
| Opus 5.5 | yes | 24.0 / 25 | 15 / 18 | 0 / 6 | $0.66 | 3.3 |
| Opus 5.5 | no | 22.7 / 25 | 17 / 18 | 5 / 6 | $0.78 | 3.5 |
- Without starchy, agents almost never noticed the "Order online" button that disappears on tablets, or the hero buttons whose labels wrap.
- Every regression was the same one: a fix narrowed the catering cards, and a heading ran out of its box. starchy reports that, so no run with it shipped it.
- With starchy, Opus fixed fewer held-out bugs. The one it missed most is a hero photo cropped to an empty background on phones, which needs visual judgment.
The same site, with the prompt a person would type: "Fix styling issues at all
screen sizes", or "Use starchy to fix styling issues at all screen sizes". The
starchy runs had three setups: starchy installed with no other trace, the
package.json script, and the script plus the agent note from
With coding agents. 6 runs per group:
| Model | starchy setup | Found starchy | Bugs fixed | Held-out fixed | Runs with a regression | Cost |
|---|---|---|---|---|---|---|
| Sonnet 5.5 | no starchy | 20.2 / 25 | 11 / 18 | 2 / 6 | $0.12 | |
| Sonnet 5.5 | installed only | 5 / 6 | 21.5 / 25 | 4 / 18 | 0 / 6 | $0.19 |
| Sonnet 5.5 | script | 6 / 6 | 23.5 / 25 | 11 / 18 | 0 / 6 | $0.17 |
| Sonnet 5.5 | script and note | 6 / 6 | 23.7 / 25 | 12 / 18 | 0 / 6 | $0.17 |
| Opus 5.5 | no starchy | 22.7 / 25 | 18 / 18 | 3 / 6 | $0.62 | |
| Opus 5.5 | installed only | 4 / 6 | 16.5 / 25 | 11 / 18 | 0 / 6 | $0.46 |
| Opus 5.5 | script | 6 / 6 | 24.5 / 25 | 16 / 18 | 0 / 6 | $0.63 |
| Opus 5.5 | script and note | 6 / 6 | 25.0 / 25 | 18 / 18 | 0 / 6 | $0.62 |
- With starchy installed but nowhere in the project files, 3 of 12 agents did
not find it. They searched for it, then asked what starchy is or fixed the
CSS by hand. None tried
npx starchy. - With a one-line prompt, agents trusted "0 problems" more, and fixed fewer held-out bugs. The note fixed this for Opus. Sonnet opened the screenshots in 6 of 6 runs with the note (0 of 6 without it), but still missed some held-out bugs.
- Sonnet without starchy was cheaper because it did less: 0.9 minutes a run, against about 2 with starchy.
Round 5 plants one bug for each readability and usability check: a heading orphan, a paragraph that runs about 150 characters wide on desktop, text at the screen edge on phones, form fields that make iOS zoom, and small footer links. That makes 30 bugs (3 held out). The prompt is the full customer report, with starchy 0.6.0 and 6 runs per group:
| Model | starchy | Bugs fixed | Readability and usability fixed | Held-out fixed | Runs with a regression | Cost | Minutes |
|---|---|---|---|---|---|---|---|
| Sonnet 5.5 | yes | 28.2 / 30 | 4.7 / 5 | 11 / 18 | 0 / 6 | $0.20 | 2.2 |
| Sonnet 5.5 | no | 24.7 / 30 | 2.2 / 5 | 14 / 18 | 5 / 6 | $0.21 | 1.5 |
| Opus 5.5 | yes | 28.0 / 30 | 4.2 / 5 | 18 / 18 | 0 / 6 | $1.14 | 6.1 |
| Opus 5.5 | no | 24.8 / 30 | 1.8 / 5 | 18 / 18 | 0 / 6 | $1.35 | 7.1 |
- Without starchy, no agent fixed the heading orphans or the long paragraph, and 2 of 12 fixed the small footer links. The text at the screen edge and the iOS zoom show on phones, so agents found those without starchy.
- Words matter. The first
--helptext called these checks ones that "rest on taste", and agents with starchy left the long paragraph alone in 11 of 12 runs. With "readability and usability" in its place, Sonnet fixed it in 6 of 6 runs and Opus in 2 of 6. Opus kept the long lines in the other runs because the prompt says to keep the design where it works. The table shows the starchy runs with the new text; the runs without starchy are from the round before, with the same site and prompt. - Opus hid the "Most popular" ribbon in all 12 runs, with and without starchy, where earlier rounds mostly showed it. Opus also cost about 70% more per run than in earlier rounds, in both groups, so compare costs within one round.
- Sonnet with starchy fixed fewer held-out bugs (11 of 18, against 14), most often missing the hero photo that is cropped to an empty background.
Limits: 6 runs per group, on a site built for the test. The summary of every
round, including earlier ones and a one-page benchmark, is in
bench/*/results*/summary.md. To run it:
node bench/run.js --bench multi (needs a logged-in claude CLI). Add
--prompt simple for the one-line prompts, and --setup pkg or
--setup pkg-md for the starchy setups.
ReDeCheck (Walsh, Kapfhammer and McMinn, 2017) had the same core idea: sample a page across viewport widths and report the layout failures between them. Its research found that fixed steps together with the widths declared in the CSS sampled best, and it defined the small-range layout check. starchy uses both ideas. ReDeCheck runs on Java and Firefox 46.
MIT
