Parity harness
Commands, the check matrix, design mode and adding a widget.
Proves a migrated widget matches live afl.com.au. It records the live page once into git, then on every run replays that recording offline next to the new build rendered from the same data, and compares them pixel for pixel, style for style and interaction for interaction. Club sites are checked through their theme tokens.
The Software Factory runs it through the migrate-afl-widget skill. The wider framework is in the widget migration docs.
Quick start
From the repo root, after bun install:
bun run --cwd tools/parity parity:installbun run --cwd tools/parity parityparity starts apps/web through portless at https://parity.afl.localhost with PARITY=1 CONTENT_SOURCE=fixtures (or uses PARITY_BUILD_URL if set) and runs every widget spec. Open the report with:
bun run --cwd tools/parity playwright show-report .parity/reportCommands
| Command | What it does |
|---|---|
parity:install | Installs the Chromium build Playwright needs |
parity | Runs every widgets/*/widget.parity.ts; add -g <text> to filter by widget id or cell |
parity:record <widget-id> | Records the live pages at every width (plus the club reference) into live.har.zip, writes live.json (the frozen clock) and the fixtures from extract.ts |
parity:inspect <widget-id> [filter] | Dumps every element's matched live CSS rules, pseudo-elements and media queries included, into .parity/inspect/; club as the filter inspects the club reference and lists the club theme tokens it uses |
parity:themes | Records the 30 club theme tokens from all 18 club sites into themes/live-tokens.json and regenerates packages/config/src/club-themes.css |
parity:rebuild <widget-id> | Starts a regeneration: records a rebuild on the tracker and snapshots the prior field mappings, latest runs and the component's fixtures against it (--component, --trigger, --rule, --change-log, --track); see Regenerating a widget |
parity:publish | Uploads the screenshots in .parity/shots/ and posts .parity/runs.json to the tracker (TRACKER_URL, TRACKER_API_TOKEN); fails if the tracker rejects either. After parity:rebuild it also tags the runs with the rebuild and publishes its report |
Run from inside tools/parity, drop the --cwd tools/parity.
How a check works
- Live side. Replays
live.har.zipwith every unrecorded request aborted, so nothing reaches the network; API stubs from the spec override the HAR; the clock is frozen at the recording time; fixed and sticky elements outside the widget are hidden; the widget root is scrolled into view and its images decoded. - Build side. Opens
apps/webat/parity/<component>/<fixture>, which renders the fixture through the real block registry at the live widget's width, background and document offset (sub-pixel position changes rasterisation). Images come from the same HAR, so both sides decode identical bytes. - Checks per cell.
screenshot: the widget at a 0.05 per-pixel colour threshold (absorbs anti-aliasing of a few levels, not a colour or position change). By default zero pixels may differ. SetPARITY_SCREENSHOT_THRESHOLD_PCT(percent of pixels, default 0) to allow a few: a difference within it is a yellowwarn(counts as passed, never fails the run), above it fails. Any difference attachesdiff.png, the build with differing pixels in yellow. Decision (OQ-1): the threshold is a share of pixels, not a colour distance.styles: computed styles, attributes and boxes of everypartin the spec.behaviour:<name>: each hover, focus or click, followed by both checks again.tokens(club cells): every club theme token resolved at the widget root equals that club's live value.
- Screenshots. Every screenshot and behaviour check keeps the live and build images, pass or fail, in
.parity/shots/and in the HTML report; the tokens check keeps the club render.parity:publishsends them to the tracker, where the item page compares them.
Regenerating a widget
A rebuild is triggered by hand, per widget. Run it from tools/parity:
bun src/rebuild.ts <widget-id>creates the rebuild record (the same onePOST /api/items/:id/rebuildswrites,snapshotincluded) and keeps the prior build in it: the item's field mappings, its latest runs and screenshots, and the component's fixtures. It writes.parity/rebuild.json.- Regenerate the widget, then run
parity. parity:publishsees.parity/rebuild.json, compares each newbuildscreenshot with the prior one (yellow…-change.pngfor differing pixels, atPARITY_SCREENSHOT_THRESHOLD_PCT), publishes the runs with the rebuild id and asks the tracker for the report, then removes the file.
The report on /items/:id lists the field mappings and block fields that were added, removed or changed, the style and behaviour checks that changed state, and the changed screenshots side by side. A dropped field and an added one are linked as a replacement when the new field's mapping declares replaces: "<old target>" (POST /api/items/:id/mappings takes fieldType and replaces), or are suggested when they share a legacy source or a similar name; a suggestion is shown as such until replaces confirms it.
Design mode (redesign track)
Redesigned widgets have no live baseline. A widgets/<widget-id>/widget.design.ts spec (defineDesign, run by design()) renders the build at each Figma frame's viewport and width and runs:
figma: screenshots the build and compares it withwidgets/<widget-id>/figma/<viewport>.png, the frame exported from AFLDS-Web-Library. It records the share of differing pixels asscoreand both images; it never fails on a difference, only when the export is missing.a11y: axe with WCAG 2.1 AA rules on the widget. Any violation fails.behaviour:<name>: performs the hover, focus or click, compares with<viewport>-<name>.pngwhen it exists, and runs axe again.
parity:publish sends the scores and screenshots to the tracker, where the review grid compares Figma with the build. The redesign-afl-widget skill describes the whole flow.
Matrix
| Dimension | Values |
|---|---|
| Widths | Derived from the --breakpoint-* screens in packages/config/tailwind-preset.css: the existing 375, 600, 768, 1024, 1280, 1440 plus one width at the top of any live CSS band they miss (today 1159). Falls back to the six if the config is missing. Capped at 25 width cells per widget (maxCells in the spec overrides); trimmed evenly, with a warning |
| Site | afl.com.au for pixel, style and behaviour checks |
| Clubs | Token check at 1280: all 18 if themeable: true, Carlton only otherwise |
| Mode | Light; a source with mode: "dark" adds a .t-dark cell when the live widget has one |
Club sites adopt the AFL markup with club theme tokens, so club cells compare tokens, not aflc-web markup.
Adding a widget
widgets/<widget-id>/
widget.ts defineWidget({ id, component, themeable, clubReference, sources, parts, behaviours, stubs, hide, mask })
extract.ts export async function extract(root, source) -> Block JSON fixture
widget.parity.ts parity(spec)
live.har.zip recorded by parity:record
live.json recording timeidis the tracker id;componentis the folder inpackages/ui/srcthe fixture and widget live in.partspair a live selector with a build selector ([data-slot=...]);:scopeis the widget root.- Fixtures are written to
packages/ui/src/<component>/fixtures/<fixture>.json. hideandmaskare only for things outside the widget's control; say why in the PR.
Notes
- HAR files are a few MB per widget. Track
widgets/**/*.har.zipwith Git LFS before many widgets land. - Re-record when the live widget changes on purpose; the new baseline shows up as a diff in review.
- The web server step builds
apps/web's workspace dependencies first, sopackages/uichanges are always in the render. - Passing
--reporteron the command line replaces the configured reporters, including the one that writes.parity/runs.json; filter with-ginstead. - Tests:
bun test src(style diffing, HAR cleanup, check names, theme CSS generation).
Regeneration, the screenshot threshold and the matrix widths are described above and in How the parity harness works.