| name | screenshots |
| description | MANDATORY for every change that alters rendered UI in NinerLog — capture before/after screenshots with the harness in scripts/screenshots and look at them. Load and run before touching any JSX, CSS, Tailwind class, translation string, icon, or layout, and before reporting any UI work as done. Also covers adding a screen to the harness, fixing a target that renders empty, and reviewing empty/error/dark-mode states. |
Screenshots
A UI change is not done until you have looked at it. Tests pass on markup nobody would ship: an empty state that renders a raw translation key, a page whose column jumps 300px wider than its neighbour, text that vanishes into the dark-mode background. None of that shows up in vitest. It shows up in a screenshot.
So: capture before, make the change, capture after, open both. Every time.
When this is mandatory
Any diff that touches:
- JSX or
className on anything rendered
src/index.css — tokens, @layer components, base styles
- a user-facing string, in either locale
- an icon, a layout, a page shell, a modal, a badge
Not mandatory for pure hooks, API-client, or type-only changes that render nothing new.
The loop
npm run shots -- before
npm run shots -- after
Scope it to what you touched — the full run is ~35 screens × 2 themes:
npm run shots -- after flights flights-modal empty-flights
npm run shots -- after --mobile
npm run shots -- after --mobile --fold
npm run shots -- after --theme=dark
npm run shots -- --help
Measure as well as look. --audit walks the same targets and reports what
the eye misses — horizontal scroll, targets under the minimum for the input
device (44px touch, 24px pointer), text below 11px, and how much of the column
each page uses:
npm run shots -- --audit
npm run shots -- --audit --mobile
A list page that does not report [100% of column] is leaving desktop width on
the table.
Output lands in .screenshots/<label>/ — gitignored, wiped at the start of each run for that label.
If you changed the tree before capturing before, get it back with git stash or git checkout main -- <files>, capture, then restore.
What the harness is
scripts/screenshots/ — four files, no API and no backend needed:
| File | Holds |
|---|
capture.mjs | CLI, dev-server bootstrap, browser, the capture itself |
audit.mjs | the measurements behind --audit |
targets.mjs | the list of screens, and how to reach each one |
fixtures.mjs | the API responses, keyed by path |
Every /api/v1/** request is answered from fixtures.mjs via page.route, so screens render with a logbook that has flights in it, a rating about to lapse, and a credential that already expired. The session is seeded into localStorage before first paint; the welcome tour is marked seen so it never covers the shot. The dev server starts automatically if one is not already listening.
Reading the pairs
Look for the things only a picture shows:
- Both themes. Light-only classes look fine until the dark shot. Every light class needs its
dark: counterpart.
- Empty and error states, not just the happy path —
empty-* and error-* targets exist for this.
- The page column. A list page fills it; only prose (960px) and forms (640px) are bound narrower. Check
--audit's [% of column].
- Icons. lucide, one size per role. An emoji in a shot is a finding.
- Raw keys.
nightNotApplicable or MEDICAL_CLASS_2 on screen means a missing translation or an untranslated enum.
- Mobile.
--mobile for anything touching layout; the app is mobile-first and the bottom nav eats 56px. Note that a full-page capture paints fixed chrome once, at the top — so the header and bottom nav land mid-image. --fold is the one that shows them where they really sit.
A shot that prints ⚠ in the run output hit a page error — that is a bug in the change, not in the harness.
Adding a screen
A screen with no target is a screen nobody reviews. Add one to targets.mjs:
{ name: 'my-page', path: '/my-page' },
{
name: 'my-page-modal',
path: '/my-page',
act: async (page) => {
await page.getByRole('button', { name: /add thing/i }).first().click();
await page.waitForTimeout(600);
},
},
{ name: 'empty-my-page', path: '/my-page', empty: true },
{ name: 'error-my-page', path: '/my-page', fail: true },
{ name: 'my-public-page', path: '/public', anonymous: true },
When a screen renders empty
The hook asked for a path fixtures.mjs does not answer, so it got null. Add the path to ROUTES in fixtures.mjs, matching the shape in src/api/schema.ts. Watch for wrappers — /aircraft returns { data, pagination }, /licenses returns a bare array, /announcements returns { announcements, hints }. Getting the wrapper wrong renders an error boundary, not an empty page.
Fixture dates derive from a pinned TODAY, so "expires in 18 days" stays 18 days next month. Keep it that way — a fixture built from new Date() makes every capture differ from the last.
Environment
The harness uses Playwright's bundled Chromium. If the sandbox ships its own at a fixed path, point at it:
SHOT_CHROMIUM=/opt/pw-browsers/chromium npm run shots -- after
SHOT_BASE_URL=http://localhost:4173 npm run shots -- after