Design & engineering · 2025

drift measures the system a site ships

A crawler, a queue and an audit. Every colour, type, spacing, radius and shadow, attributed back to the pages using them.

  • TypeScript
  • Playwright
  • Express
  • BullMQ
  • WebSockets
  • CIEDE2000

What drift is

Point drift at a live URL you do not control. It crawls the site and reports the design system that was actually shipped: every colour, type size, spacing step, radius, shadow and border in use, deduplicated, perceptually grouped, and attributed to the pages it appears on. On top of that inventory sits a diagnosis, a plain-language health line and a verdict per category, each measured against a stated reference.

What that takes is a piece of infrastructure rather than a page of maths. A crawl drives headless Chromium over a site of unknown size and shape, so it is slow, it fails part-way, and it has to outlive a request: it runs as a job on a BullMQ queue backed by Redis, worked by a durable Express service that reports progress over a WebSocket and answers to a published contract. The colour science is the last mile. The three sections after this one are the machine that gets there.

Everything is computed. The crawl, the aggregation, the verdicts and the export run with no API key and no model in the loop, so a run costs nothing and the same site audited twice gives the same answer. Judging whether a change between two versions was intended is a different problem that needs two sides to compare, and that work belongs to loom, not here.

picocss.com

Design Health

1 of 28 text/background pairs fail WCAG AA, 4 of 31 colours are near-duplicates, 6 of 11 type sizes fall off the scale, and 12 of 20 spacing values miss the 4px grid. Radius and shadows hold steady.

Colours314 near-dupes
Contrast281 fail AA
Type116 off-scale
Spacing2012 off grid
Radius20 near-dupes
Shadows3in use
each clause is a count against a stated reference.

The colour work folds perceptual near-duplicates together with CIEDE2000, the same colour science vault and haus lean on. On this capture it groups 31 distinct colours into 6 families and flags 4 near-duplicates, one of which is an opacity variant of white rather than a second colour.

The service contract

The backend is a standalone service, not a UI helper. The client is one consumer of the same API a CI job would use, which is why the backend is plain Express rather than Next.js API routes: it needs a durable process to own a persistent WebSocket, the Playwright workers and the BullMQ queue, none of which fit a request-scoped, serverless target. The API is written down rather than described: openapi.yaml in the repo is an OpenAPI 3.1 document covering every endpoint, which is what makes the acceptance suite two sections down able to test the contract rather than the implementation.

Four rules hold the contract together. Progress arrives over a WebSocket for liveness, but /crawl/:id/result is the authoritative completion signal, so a dropped socket degrades to polling rather than hanging. An unusable URL is rejected at the edge with a 422, not queued as a job that can only fail. A crawl that reached nothing is a failure carrying the worker's reason, never a successfully audited empty site. And the caller-supplied callbackUrl is treated as an SSRF vector.

// src/queue/webhook.ts — callbackUrl is caller-supplied, so it is an SSRF vector
const resolved = await lookup(url.hostname, { all: true })

// a public NAME can still resolve to a private ADDRESS
// (localtest.me → 127.0.0.1), so resolve BEFORE the check
if (!allowlisted && resolved.some(r => isPrivateAddress(r.address, r.family))) {
  throw new Error("callbackUrl must not point at a private or loopback address.")
}

The host is resolved before the private-address check, because a public name can still point at the loopback interface. Delivery is best-effort and never fails a crawl that succeeded: a webhook the receiver cannot accept is not a reason to fail an audit the API can still serve.

The pipeline

Inside that service, a crawl is a queued job running one chain end to end. drift crawls sites it does not control, so a crawl is slow and often fails part-way. The pipeline is deterministic: the same site produces the same audit, with no model in the loop. The chain is discover, crawl, extract, normalise, aggregate, audit, export.

the second readThe CSSOM, for the unitsand token names thecomputed pass discarded.discoverFinds the sitemap, orsame-origin links fromthe root if none.crawlDrives headlessChromium, same-origin,to a page cap of ten.extractReads every elementin-page, to a ceiling of12,000 so one heavy pagecannot OOM it.normaliseTurns raw CSS stringsinto typed values,Node-side and pure.aggregateFolds each page intotoken tallies, after thecrawl, not per page.auditProduces the inventory,the contrast findings,a verdict per category.exportWrites one JSON artefactthat leads with thediagnosis, for a CI job.

The crawl is bounded on purpose. The design language lives in the shared stylesheet, so a handful of pages captures the system and later pages mostly repeat tokens already seen. What extra pages buy is attribution, not new tokens, so the cap is ten same-origin pages.

A crawl once exhausted the heap on a real content site, then crash-looped as BullMQ retried the poison job. The cause was not the page count but that the pipeline retains every element of every page until the audit runs, so one animation-heavy page can exhaust the heap alone. The cheap half shipped: a per-page ceiling of twelve thousand elements, and the page cap cut from forty to ten. The real fix, folding each page into tallies as it arrives so memory scales with distinct tokens rather than elements times pages, is documented and not yet built.

Proving the contract

A service with a published contract is only as good as the evidence it honours it. A separate repository, drift-tests, holds black-box acceptance tests for that contract: six feature files, seventeen scenarios, driving the real endpoints over HTTP and asserting on the responses. It imports nothing from drift and knows nothing about its internals. drift already has eighty-three unit tests across twelve files over the pure functions, and they cannot tell you whether the running service honours its contract; a further twenty-four contract tests inside the repo assert every response against the published schema, which is the same question asked from the inside. This is the outside-in view, and it echoes the BDD regression discipline from pendula, applied here to a product I own.

The suite never touches the public internet. It serves a small, deliberately-inconsistent fixture site on 127.0.0.1, three same-origin pages seeded with near-duplicate blues, off-grid spacing, off-scale type and a failing-contrast pair, and points drift at that. Same input, same audit, every run.

HTTPcrawlsthe suite serves the site drift crawlsdrift-testscucumber-jsdriftthe running backendfixture site127.0.0.1 · 3 pages

The fixture is wrong in four measured ways, each chosen to trip one signal: #3366cc beside #3467cc at ΔE 0.3, padding of 13px and 7px off a 4px grid, font sizes of 15, 23 and 31px off the closest modular scale, and #999 on #fff at 2.85:1, which fails AA. The audit has to find all four or the run is red.

Ten of the seventeen scenarios assert a failure path rather than a success. That ratio is the point of the suite. A happy-path test tells you the thing works when nothing is wrong, and every expensive bug drift has had lived on the other side.

drift-tests

6 feature files · 17 scenarios · 10 assert a failure path

The audit finds the seeded faults, and never over-attributes a token.

  • The audit reports its structure and summary
  • The audit surfaces the fixture's seeded inconsistencies
  • No token is attributed to more pages than were crawled
ten of the seventeen scenarios assert a failure.

One scenario pins the aggregation. If a contrast finding cited five pages when only four were visited, the fold had double-counted, so the suite asserts every finding cites no more pages than the crawl reached. No single-page test catches that.

# features/audit.feature
Feature: The audit
  A completed crawl yields a deterministic audit of every design token actually
  shipped — colours, type, spacing — with WCAG contrast findings and a summary.
  The fixture site seeds known inconsistencies, so the audit's verdicts are
  predictable run to run.

  Background:
    Given a completed crawl of the fixture site

  Scenario: No token is attributed to more pages than were crawled
    Then every contrast finding cites no more pages than were crawled

The lifecycle scenarios assert that an unreachable target ends failed with a reason and its audit is a 409, rather than a 200 carrying an all-zeros audit. The webhook scenarios assert the SSRF guard still refuses a private callback while the test backend has one loopback receiver allowlisted, so the allowlist is not a blanket open.

# features/webhooks.feature
Feature: Webhook callbacks
  A crawl can POST its finished audit to a callback URL. The target is validated
  at enqueue time — while the caller is still on the line — and any loopback,
  private or non-HTTP address is refused to prevent SSRF.

  # The test backend allowlists 127.0.0.1 (for webhook-delivery.feature), so this
  # uses a private-range address to prove the guard still refuses everything the
  # operator did NOT explicitly allow — allowlisting one host is not a blanket open.
  Scenario: A non-allowlisted private callback URL is refused
    When I enqueue a crawl of the fixture site with callback "http://10.0.0.1/hook"
    Then the response status is 422
    And the response carries an error message

CI stands the whole stack up on every push: a Redis service container, drift checked out beside the suite, Playwright's Chromium installed, the backend started in the background with the webhook variables set, then the seventeen scenarios against it. Nothing is mocked, so a green run means the service really did answer.

What the audit contains

That is the machine. The rest of this is what it produces, and why reading a site accurately is harder than it looks. Under the diagnosis is the evidence: every distinct value the site ships, in twelve categories, ranked by how often it is used and attributed to the pages it appears on. Usage is the ranking signal rather than a footnote. A value used once is noise; the 16px in this capture is used 288 times, six times more often than the next most common size, which is what makes it a token rather than a value.

Two pages populated all twelve categories, down to a single blur value and one gradient. That is the argument for a small crawl: a site's design language lives in its shared stylesheet, so the system is visible almost immediately and more pages mostly buy attribution.

Inventory

picocss.com

31 values in 6 families

  • #373c44Neutral262
  • #5d6b89Blue115
  • #969eafBlue100
  • #5c6370Neutral86
  • #181c25Blue73
  • #0172adBlue55
  • #71a4a1Teal40
  • #bb972cOrange35
  • #6f7887Neutral33
  • #2e685bTeal30
  • #934dafPurple30
  • #8b4f00Orange28
  • #ffffffNeutralopacity variant of #ffffffcc26
  • #646b79Neutral20
  • #c784b7Pink19
  • #e7eaf0Blue11
  • #982e79Pink10
  • #424751Neutral6
  • #f3f5f7BlueΔE 16
  • #2a628aBlue6
  • #0f1114Blue5
  • #fde7c0Orange5
  • #2d3138Neutral2
  • #eff1f4BlueΔE 12
  • #ffffffccNeutralopacity variant of #ffffff1
  • #23262cNeutral1
  • #000000Neutral1
  • #cfd5e2Blue1
  • #48536bBlue1
  • #015887Blue1
  • #fbfcfcTealΔE 0.81
ranked by usage: 262 uses at the top, one at the bottom.

Categories that are absent simply do not appear. A site with no gradients gets no gradient section rather than an empty one, so the shape of the report is itself a reading of the system.

Reading what was authored

getComputedStyle returns one resolved pixel number per property. It is accurate for what rendered, and it has already collapsed whatever was authored, rem, em, % or calc(), into that one number. Three things go with it: the unit the author wrote, the site's own token names, and any arithmetic the value was built from. picocss.com writes most of its spacing as calc(var(--pico-spacing) * 2) and the like; read as computed styles alone, each of those is a bare number with nothing left to say which token it came from.

So the extractor reads twice. The walk runs in the browser, skips the nodes that carry no design signal, stops at a hard ceiling, and reads each kept element with getComputedStyle and, separately, with a walk over the CSSOM.

// src/crawler/extract.ts — runs in the browser, per element
const cs = window.getComputedStyle(el)   // what RENDERED: resolved px

// …and, separately, a walk over the CSSOM for what was AUTHORED:
for (let i = 0; i < style.length; i++) {
  const prop = style[i]
  const value = style.getPropertyValue(prop).trim()
  if (prop.startsWith("--"))
    customProperties.push({ name: prop, value })   // the site’s OWN token names
  else if (PROP_CATEGORY[prop])
    declarations.push({ category: PROP_CATEGORY[prop], value })
}

Both reads were also, for a while, blind to modern colour. Pointed at a site authored in OKLCH, drift read every colour as null and reported none: getComputedStyle returns oklch(0.52 0.138 300) verbatim rather than converting it, and both halves of the probe parsed rgb() alone. Worse than the nulls, the walk that resolves an element's effective background treated an unparsed colour as transparent, so it fell through to the page canvas and every contrast pair on such a site was measured against the wrong backdrop. A tool for auditing colour, blind to the colour space colour is moving to. The fix went into haus-colour-utils as a toHex that handles both, which is where the rest of drift's colour maths already lived. It was found by integration rather than by a unit test, because every unit test on both sides was written in rgb().

The second read recovers the unit and the token names both. The panel below takes spacing alone and runs both readings over the same 203 declarations: what each API returns, then what each one actually holds. Not one of those declarations was written in px, and the resolved side has no way to say so.

getComputedStylewhat rendered

One resolved px number per property. All 203 spacing declarations on the two crawled pages arrive here as px, whatever they were written as, and collapse to 20 distinct values.

CSSOMwhat was authored

The same 203 declarations, by the unit actually written. Not one of them was authored in px.

calc 132rem 68em 3
the px reading

20 bare numbers, with nothing left to say which of them came from the same token.

2.5px1515px1377.5px3910px23315px6020px8826.4px1839.6px240px8545px152.5px155px260px1680px790px4110px1125px2135px1180px5250px4
the CSSOM reading

The 14 most-used of 62 distinct authored values. One token, --pico-spacing, and a handful of multipliers over it account for most of the set.

var(--pico-spacing)310.25rem22calc(var(--pico-spacing) * 2)16calc(var(--pico-spacing) * .5)12var(--pico-block-spacing-vertical)110.5rem10calc(var(--pico-spacing) * .25)91rem80.375rem8var(--pico-typography-spacing-vertical)8calc(var(--pico-spacing)/ 2)7calc(var(--pico-homepage-spacing-vertical)/ 2)7calc(var(--pico-spacing) * 4)60.125rem6
the same 203 declarations, read two ways.

Why the unit is a finding

getComputedStyle cannot tell px from rem, because both arrive as the same resolved number, but they behave differently for a reader who has set a larger base font size or zoomed in: px stays fixed, rem scales. The audit flags font-size authored in px for that reason, which is only possible because the unit was recovered from the CSSOM. picocss.com authors its type in rem, so the flag does not fire here.

The other recovery is naming. getComputedStyle never sees a custom property: by the time it runs, every var() has resolved to a value on some element. A site's real design vocabulary lives only in the stylesheet, so the authored pass reads it off the :root rules. drift can then show a system its own token names rather than a pile of anonymous pixels.

Recovered from the CSSOM

177 custom properties, 40 of them aliases onto another declared token. getComputedStyle discards every one. Six of the chains:

  • --pico-accordion-active-summary-color--pico-primary-hover#79c0ff
  • --pico-accordion-border-color--pico-muted-border-color#202632
  • --pico-accordion-close-summary-color--pico-color#c2c7d0
  • --pico-accordion-open-summary-color--pico-muted-color#7b8495
  • --pico-block-spacing-horizontal--pico-spacing1rem
  • --pico-block-spacing-vertical--pico-spacing1rem
the aliases come back, not just the names.

Measuring against a reference

Off-scale is meaningless without saying off what, so the reference a value is measured against is a control, not an assertion. Type is compared against any named modular ratio; spacing against a 4px or 8px grid. Every option carries its own off-count, so the row answers which scale the system is actually on before anything is picked.

6 of 11 type sizes fall off this reference · base 16px, the most-used size.

on the reference off itthe Overview verdict stays pinned to the closest fit, so exploring a hypothesis never rewrites the diagnosis
change the reference and the off-count changes with it.

The automatic pick is ranked by fewest values off, with mean relative error as the tiebreak. Ranking by error alone can crown a ratio that fits most sizes tightly but tips a couple over tolerance, which would leave the option labelled closest showing a higher off-count than its neighbour and read as a bug.

// the closest scale is the one the FEWEST sizes miss,
// mean relative error as the tiebreak
for (const r of RATIOS) {
  const scale = buildScaleToCover(basePx, r.ratio, min, max)
  const off = classifyAgainstScale(sizes, scale).filter(m => !m.onScale).length
  const err = meanError(sizes, basePx, r.ratio)
  if (off < bestOff || (off === bestOff && err < bestErr)) best = r
}

The selection drives that ruler and its table, but never the overview verdict, which stays pinned to the automatic best fit. Otherwise a reader who tried the golden ratio out of curiosity would be told their type system is failing.

Design decisions

The colour science is one published dependency. Perceptual near-duplicate clustering and WCAG contrast are real colour maths that would be error-prone to reimplement, so the audit consumes haus-colour-utils from npm rather than carrying its own. It is pure ESM with one browser-safe dependency and ships its own types, so the backend imports deltaE and clusterByPerceptualDistance by name.

The extractor was lifted out rather than copied. The per-element measuring code was drift's own, and loom needed the same measurements over a single mounted component rather than over a whole crawl. Rather than keep a second copy to drift from the first, it was published as haus-style-probe with a root option, and drift now consumes it: crawler/types.ts re-exports the package's shapes and drift's own normalise.ts is deleted. So drift installs two haus packages by name, the colour maths and the probe, and one of them started here.

BullMQ over pg-boss and an in-memory queue. A crawl is slow and failure-prone and must outlive a restart, so an in-memory queue was out. pg-boss would add a second stateful store; BullMQ brings first-class concurrency, retry and backoff, and an events stream that maps onto the per-page progress frames. One Redis, one queue, no extra database.

Build each piece standalone, one new dependency at a time. drift combines Playwright, Redis, BullMQ and WebSockets, and the failure mode is integrating them together and being unable to tell which layer broke. The rule was to add at most one new infrastructure dependency per step, so a regression points at exactly one piece. Docker is the next step in that order: planned, multi-stage to contain the Chromium binary, and not yet built.

The export leads with the diagnosis. Its one real audience is machines: a CI check to assert on, two runs to diff, a model to reason over. Shipping raw counts made the consumer re-derive the judgement drift had already made, so the export leads with the health line, the typed findings and the verdicts, then a rules block stating the ΔE threshold, grid base, detected ratio and WCAG standard. The full inventory sits underneath as evidence.

The proposals layer was cut. A second layer once projected the audited tokens onto known-good structures: a consolidated palette, a modular type scale, a spacing grid. An audit is a claim drift can defend from the evidence it collected. A proposal is a recommendation, and the only warrant drift had for one was that the result came out arithmetically tidier. Three parts survived the cut because they measure rather than recommend: the selectable references, the perceptual clustering, and the export.

Where it stands

drift is live and the audit it produces is one I trust. Four things are open. The page cap of ten is doing work that belongs to the pipeline: until each page is folded into tallies as it arrives, memory scales with elements times pages, and raising the cap means doing that refactor first. There is no Dockerfile yet, so the deployment story is a set of instructions rather than one command. The crawl reads a single viewport, which makes the whole responsive system invisible to the audit, and it reads the resting state only, so hover and focus styles are never seen.

The tests are no longer only on the backend. Eighty-three unit tests over the pure functions, twenty-four contract tests holding every response to the published schema, and seventeen black-box scenarios over the running service across six feature files and fifty-six steps. The client, which had no test script and no test files at all, now carries a hundred and eighty-two across ten files over the screens, the flow and the audit model — the largest single file of them on the model behind the audit screen, which is where the logic worth testing had accumulated.

The deployed site does not crawl on demand. A Playwright crawler behind a Redis queue is not something to leave open to strangers, so the public build replays a real captured audit and says so. Everything downstream of the crawl is genuine output, because it is the same output; only the network round trip is stubbed.

The decision log, including the layer that was cut, in DESIGN.md →