Skip to content

Latest commit

 

History

History
773 lines (615 loc) · 46 KB

README.md

File metadata and controls

773 lines (615 loc) · 46 KB

Design tooling

Start here: which audits does my change affect?

ruby bin/design/which-audits.rb              # the last commit, plus anything uncommitted
ruby bin/design/which-audits.rb HEAD~3       # a range you name
ruby bin/design/which-audits.rb --staged     # what you are about to commit

There are 30 audits here, which is more than anyone holds in their head, and the failure this prevents is a real one: a change removed fifty keyboard tab stops and altered two control sizes, and the seven audits that measure exactly those things — keyboard-audit, wcag22-audit, row-actions-audit, tooltip-audit, wcag-audit, icon-audit, table-audit — were all skipped, because the re-run list was chosen from memory.

It is not a checklist, deliberately. A checklist is memory written down and goes stale the day an audit is added. Instead every audit declares its inputs in its own header — # AUDIT-READS: RENDER — the script enumerates bin/design/ itself rather than carrying a list, and a file that neither declares nor is named as a non-audit fails the run. That last part is the whole mechanism: a new audit cannot be quietly left out of every future selection.

The bundles (RENDER, VIEWS, RUBY, CSS, ROUTES, DOCS, AUDITS) are coarse on purpose. A browser audit drives real pages, so a changed view or Stimulus controller really can move any of them — expect "most of them" for anything on the render path, and a much shorter list for a documentation change. It prints what it considered and did not select, and a single pasteable command to run the selected set.


audit.js renders a page in headless Chromium as a signed-in bank admin and reports the things a design migration silently gets wrong: sidebar geometry, the computed h1/card/active-nav tokens, leftover Bootstrap/AdminLTE class names, Font Awesome icons that render as nothing on a Tailwind page, and heading order.

bin/start                                 # or: bundle exec rails s -p 3003
node bin/design/audit.js /dashboard 1400  # path, viewport width

Requires playwright on the Node path and a dev server on port 3003 seeded with the standard dev credentials (org_admin1@example.com / password!).

Read the output as a checklist: legacyClassesPresent and faIcons must both be empty/zero on a migrated page, hOrder must not skip a level, and activeNav must be brand-50 on brand-700.

route-sweep.js does the same checks across every HTML screen the router knows about — 142 of them, as a super admin, a bank admin and a partner — and prints only what is wrong.

bin/start                # or: bundle exec rails s -p 3003
pw bin/design/route-sweep.js

It asks Rails for the list (route-targets.rb, which substitutes a real record id for :id) rather than carrying one. That is the whole point. Its predecessor sweep.js walked a hardcoded list of 56 paths, and a list goes stale in silence: the three historical trend pages were in the sidebar and never on it, and they sat with no page header and no <h1> for the length of the migration while every audit reported clean. On its first run route-sweep.js found eleven unlabelled selects in the partner portal, which the old list never visited.

A 404 from route-sweep.js is listed separately from the design findings rather than mixed in with them. It used to mean a route with no action behind it — resources :x generates seven and most controllers implement four — and 13 of the sweep's targets were that. They are gone; use dead-routes.rb for that question now. What is left in the bucket is the sweep's own approximations: a path whose :partner_id was filled with a user's id, and two records the role being used cannot see.

page-audit.rb reads the templates rather than rendering them, and is the only audit here that needs neither a browser nor a server. It reports two severities — a DEFECT is wrong now (a class nothing defines, an inline style, layout built from &nbsp;, Title Case in a heading, a page with an <h1> and no page_header), and DEBT renders correctly but has a component's classes pasted inline, so a change to the component will never reach it. It exits non-zero on a defect and reports debt without enforcing it.

bin/rails runner bin/design/page-audit.rb          # all kinds
bin/rails runner bin/design/page-audit.rb action   # one kind

Five kinds, and the fifth is a catch-all on purpose. show, index, form and partial are the RESTful shapes; action is everything else, which means a template named after a collection action — items/inventory.html.erb — cannot fall out of the audit by being named oddly. It could before: those four patterns were treated as exhaustive, and 25 views matched none of them and were scanned by nothing. Adding the catch-all also surfaced that the mailer exclusion knew about *_mailer/ but not Devise's users/mailer/, so six HTML emails were being audited as app pages.

The hand-rolled card check matches a surface, not a string. A card is white, hairline border, rounded-2xl, shadow-sm; the check looks for those four tokens inside one class attribute, in any order. It used to compare against the exact rounded-2xl border border-slate-200 bg-white shadow-sm substring and skip any file that also rendered shared/essentials/card — two holes that between them hid every hand-rolled card in the app. One padding utility (bg-white p-4 shadow-sm) defeats a substring, and a file can render the component properly in one place and paste it in another.

It is not a per-kind check. It sweeps app/views, app/helpers, app/javascript and app/components, including shared/essentials/, because the last two copies were in exactly the places the per-kind scan cannot reach: essentials_stats is a helper, and _disclosure is a design system partial that every other check skips as "the definition, not a copy". Since .card-surface exists there is no definition left to protect — the component uses the class like everybody else. It reports 0, and the remedy for a finding is always the same: use the class.

A second sweep asks the same question of the icon tile. A tone-coloured, fixed-size rounded box that is not rounded-full should have come from essentials_icon_tile. rounded-full is the whole discriminator, and it is doing real work: a circle is an avatar or a numbered step badge, which design.md keeps deliberately disjoint from tiles, and both appear in the app. The reports hub was the one copy, and it had drifted on size, radius and text colour — 28px/rounded-lg/ text-brand-700 against the helper's 36px/rounded-xl/text-brand-600 — which is what a single hand-rolled component looks like after a while. The helper takes size: :sm now and the hub uses it.

The bare-card check moved from /class="[^"]*\bcard\b[^"]*"/ to a class-token split at the same time. \b does not stop at a hyphen, so that pattern matched inside card-surface, content-card and data-card — harmless while nothing legitimate contained the substring, and then every card in the app reported as a dead Bootstrap class the moment one did.

How to test it. The script proves the detector before it reports anything, the same way undefined-classes.py proves its extractor: a table of markup fragments with expected answers, including the padding-interleaved case that used to slip through and a split-across-two-elements case that must not match. Break the detector and the script refuses to run —

card detector is wrong: "<div class=\"…bg-white p-4 shadow-sm\">" => false, expected true.
Fix it before trusting any result below.

and exits 1 without printing a report, because a check that silently reports zero is the failure mode that looks like success. To exercise it end to end, add <div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"> to any view and re-run: that file should appear under debt. Debt does not change the exit code — only defects do.

overlay-audit.js also looks for native browser confirms, which is the one overlay no DOM query can find.

window.confirm is browser chrome, not an element: this audit opens <dialog>s and reports nothing, axe scans the document and has nothing to scan, and the system suite drives it with Capybara's accept_confirm, which only works on a native dialog — so a green suite is evidence for it. The same shape as the toastr message below the fold and the frozen-column shadow that drew nothing: the assertion pins the thing that is wrong.

The only way to see one is to listen for the dialog event the browser raises, which is what checkNativeConfirms does. Clicking is safe — the handler dismisses, so nothing is submitted. It reports 2 today, both on /transfers, from the 44 confirm: call sites in the app.

flash-of-hidden-audit.js finds content that is painted and then hidden by JavaScript.

pw bin/design/flash-of-hidden-audit.js

It loads each page with waitUntil: "commit" -- before scripts run -- lists everything visible, then lists it again once the page has settled, and reports what disappeared. A controller that hides something in connect() has already let the reader see it.

Found the reported "ghost button that appears for a second" on /distributions/new (the shipping cost field) and the same defect on /manage/edit (the reminder day fields). <select> is excluded because select2 replaces one with its own container, which is a swap rather than a hide.

disclosure-audit.js checks every conditionally revealed field against the rule in design.md.

pw bin/design/disclosure-audit.js

Per reveal: it starts below the control that reveals it, follows it in the DOM so Tab reaches it next, is marked with the indent and the 4px left rule, and hangs 6px off its trigger's left edge -- which is the check that catches a field parked in a column of its own. It exits non-zero on a defect.

Reveals are found by asking the page, not the source: anything a control points at with aria-controls, plus the two pre-component target names, so a reveal that has not been migrated still appears in the report rather than dropping out of it. A nested reveal is measured with its whole ancestor chain un-hidden and then put back -- otherwise it measures 0x0 inside a collapsed parent and reads as a defect that is not there. Two exemptions, both read from data-conditional-reveal rather than guessed: a table cell, whose column is the relationship, and a plain reveal, whose content already carries its own box.

Reported as "the location and size of the shipping cost field does not make any sense". Currently 10 reveals, 0 defects.

template-compile-audit.rb compiles every view template, and says which ones will not.

bin/rails runner bin/design/template-compile-audit.rb

One second, and it closes a gap four other checks left open. A sweep that rewrote 55 row actions interpolated Python's None into one of them -- icon: "bi-check-circle"None in distributions/_pickup_day_row. erb_lint passed, because the ERB tags are well formed and it does not compile the Ruby inside them. rubocop passed, because it does not read templates. All 3,159 specs passed. And /distributions/pickup_day returned 200, because Rails skips a partial rendered through collection: when the collection is empty and never compiles it -- the page 500s the moment a pick-up exists for the day being looked at, which no spec sets up. Brakeman found it, as a parse error under a heading nobody reads.

spec/views/every_template_compiles_spec.rb runs the same check in CI. Verified to fail: putting the None back reports the file and the line.

A compiled template is a method body, so the source is wrapped before compiling -- <%= yield %> is legal there, and treating "Invalid yield" at the top level as a defect reported four healthy partials on the first run.

citation-audit.py classifies the industry citations in design.md and the decision log.

python3 bin/design/citation-audit.py          # the summary and the risky ones
python3 bin/design/citation-audit.py --list   # every claim

It cannot check whether a claim is true -- nothing here can browse. It finds claims whose form outruns the checking behind them: naming an artefact (OverflowMenu, slds-truncate, <Table sticky />) is evidence a reader can look up and disagree with; asserting that four systems all do something, with nothing named, is not.

Written after a real error. design.md justified the selection bar covering the filter row with "three of the four keep it in the toolbar", citing Carbon, Material, GitHub and Gmail -- and only Carbon takes the filters away. The other three were counted as agreeing by describing them all as "keeping it in the toolbar", which is true of the category and false of the specific behaviour. 32 claims were in that form; all are now evidenced, softened to "observed", or annotated.

python3 bin/design/citation-audit.py --check   # fails if the unevidenced count has grown
python3 bin/design/citation-audit.py --bless   # record the current count as the baseline

--check is the part that prevents a repeat. It holds a baseline in citation-baseline.json and exits non-zero when the number of claims asserting agreement across four or more systems, with no artefact named, goes up. It cannot tell whether a claim is true -- nothing here can open another product -- but it can stop the count growing unnoticed. Verified to fail: adding one sentence of the old form takes it from 2 to 3 and exits 1.

The two claims still counted are the proximity test's known limit, and it says so in its own output: an absolute about this app ("every tab's controller goes in active_on") sitting within 90 characters of a system's name. Both are measured claims about our own code, which is where an absolute belongs. The standard they are measured against is "Citing another system" in design.md.

layout-shift-audit.js scores what moves after it is drawn, on every screen.

pw bin/design/layout-shift-audit.js
pw bin/design/layout-shift-audit.js --width=390   # a phone, where tables stack into cards

It reads Chrome's own layout-shift performance entries -- the same ones Cumulative Layout Shift is scored from, thresholds 0.1 good, 0.25 poor -- and names the elements that moved. Run it at a phone width as well as a desktop one: the biggest shift in the app was only visible at 390px, where tables stack into cards. Shifts the reader caused are excluded by the browser (hadRecentInput), so opening a disclosure or applying a filter does not count; the dev-only profiler and Bullet badges are filtered out here, because they are not in production and have fooled a DOM-reading audit on this project before.

Found three things no other audit could see: the historical trend charts at CLS 0.352, because an empty container was inflated to 850px on connect; the donation form's four source selects, drawn and then hidden for 100px of reflow; and, at 390px, six screens past "poor" -- /admin/base_items at 0.658 -- because tables were restacked into cards by JavaScript after paint. All three are fixed; the worst score in the app is now 0.068 at 390 and 0.007 at 1400. The second was invisible to flash-of-hidden-audit.js because that audit excused every <select> for select2's sake -- it now recognises the actual swap instead.

tab-set-audit.js checks that a set of page tabs behaves as one place.

pw bin/design/tab-set-audit.js

Two invariants, neither of which any other check here asks about. The strip does not move: every tab in a set puts its tab strip at the same height, so arriving somewhere does not shift the thing you just clicked. The rail still says where you are: a tab living under a sidebar entry keeps that entry marked and its group open.

Both were broken. Measured before: /partners at y=228 against /partner_groups at y=174, a 54px jump, because the filter bar sat above the card holding the strip; and on /partner_groups and /item_categories no sidebar group was open at all, because active_on named only the first tab's controller. Currently 7 tabs across 3 sets, no findings -- and verified to report the 54px spread when a filter bar is moved back above its card.

tooltip-audit.js checks that an icon-only control says what it is, the app's way.

bin/rails runner bin/design/route-targets.rb > /tmp/targets.json
pw bin/design/tooltip-audit.js

Per control: it has an accessible name, it has data-tooltip, it has no title -- which would draw the browser's own tooltip on top of ours a second later -- and the two strings agree. Then, once in a real browser: the bubble appears on keyboard focus, is aria-hidden so the action is not announced twice, and Escape dismisses it. None of that is visible to a class-name check, and the keyboard case is the one title cannot do at all.

Two exclusions, both declared on the element rather than guessed at. The kebab trigger, because aria-haspopup already identifies it and "More actions for " repeated down every row is noise rather than help; and a chip's dismiss, marked data-chip-dismiss, because the label it removes is right beside it.

It used to read main .cell-actions on 25 hardcoded pages and report 0 defects while there were 14. The rule is about an icon-only control, not about where one sits, so a funnel in a data cell on /events was never looked at; and a hardcoded list goes stale in silence, so nine /new and /edit forms with an untooltipped barcode-scan button were never visited. It reads the route targets now, like route-sweep.js and icon-audit.js, and takes every control whose visible text is empty. Currently 640 icon-only controls across 153 screens, 0 defects.

wcag-manual.js covers the criteria axe cannot see, and has two scopes.

pw bin/design/wcag-manual.js          # expensive checks over a sample, cheap ones over all. ~40s
pw bin/design/wcag-manual.js --all    # every check over every screen, all three roles. ~4.5 min

Reflow, 200% zoom, text spacing and a full tab traverse each resize the viewport two or three times, so they cost seconds per page where a title or a lang attribute costs milliseconds. The default samples them; --all does not.

Sampling is least defensible for exactly the expensive checks. Reflow and text-spacing failures come from one page's content -- a wide table, a long unbroken string -- so eight pages say almost nothing about the other hundred and forty. Widening the cheap checks took 2.4.2 from "0 failures on 8 pages" to 14 on 92, and the first --all run found ten more on the admin and partner screens that a single-role pass had never visited.

One definition of each check, with the scope as an argument: a second script for the thorough run would be a copy, and a copy drifts.

No audit here carries a list of pages any more. Two exceptions, both deliberate: audit.js takes a path on the command line, and audit-selftest.js uses one fixture page because it is testing the checks rather than the app.

overlay-audit.js was the last, and it was slow rather than merely wide: 58 seconds a page, and always had been. The cause was three .click().catch(() => {}) calls each waiting Playwright's default 30-second timeout and swallowing it. Bounded clicks, a visibility filter and condition waits took it from 525s for 9 screens to 254s for 154 — 1.6s a screen.

Getting there widened seven audits and found four things:

button-audit Six real violations on show pages its list of index pages never visited. /donations/1 and /purchases/1 emitted the primary action second; /requests/1 gave the last slot to the destructive Cancel — the fault reported for /product_drives and fixed there but not here
route-targets.rb The enumerator itself was missing a screen. SKIP_ACTION matches on the action name alone, so inventory — there to skip a storage-locations partial — also skipped items#inventory, a full screen in the item catalogue's tab strip. Every audit built on the file was blind to it
sweep.js Deleted. route-sweep.js performs every one of its checks, so keeping it was keeping a hardcoded list beside the thing built to replace it
table-audit Its list still named /users, deleted in August

And three findings that were the audits' fault rather than the app's, each confirmed by measuring before changing anything: a disclosure trigger inside a collapsed accordion has a zero-size rect and so matches no grid column; a page can display a tab strip without being one of its tabs; and /partners/1/approve_application redirects to /partners carrying a flash bar, which puts the strip 72px lower.

targets.js is the seam between the audits and the application, and the only file that knows this is a Rails app. It supplies the list of screens, the three roles, signIn and visit.

It replaced 21 hand-copied signIn functions, seven copies of the role predicates and six of the targets-file read. Four of the copies had drifted: one did not sign out first, so a second role silently reused the first one's session, and one waited on networkidle, which never settles on the slowest screens here.

The targets file regenerates itself when it is missing or older than config/routes.rb, so an audit cannot quietly run against a stale list of screens. TARGETS_CMD overrides the command that builds it — that one line is the whole Rails dependency.

audit-selftest.js tests the audits, by breaking a page on purpose and by leaving it alone on purpose.

pw bin/design/audit-selftest.js

Two controls per check. Positive: the page is broken in the way the criterion is about, and the check must report — this catches a check that examines nothing. Negative: the page is changed in a way that is not a violation, and the check must stay silent.

The negative control is the one that was missing. Five checks reported failures the app did not have, each of them "verified" by planting the defect it catches, and each of them passed that test: a check that fires when it should not still fires when it should. All five would have been caught by a benign control — a table that only just overflows, a region already scrolled, a longer page, an sr-only label, a transitioned focus ring.

It runs in CI on every push and pull request — .github/workflows/audit-selftest.yml, the only workflow that boots a real server and drives it with Playwright. Run it yourself before pushing a change to a check; it takes eleven seconds.

The controls build the structures they need rather than relying on the page having them. They first ran against a development database with a session's worth of data, where /distributions overflows and grows a scroll rail; on a freshly seeded database it does not, .table-rail-track was null, and half the controls threw. A self-test that depends on how much data happens to exist is not a test — and in CI it would have been a red build about nothing.

Currently 13 controls over 6 checks, 0 wrong, in 12 seconds.

The sixth check is the short-viewport chrome measure out of responsive-audit.js, and it is here for a reason worth stating: it has no live positive anywhere in the app. Once the frozen actions column stopped counting as vertical chrome, no screen crosses the 50% threshold — a full run considers 30 pinned elements across 146 page visits, all of them .table-rail at 24px of 360 — so the run exercises the measurement and never the reporting. These two controls are the only thing that would notice if the threshold arm broke, which is exactly the case for putting a check in here rather than trusting a green run.

wcag22-audit.js covers the six criteria WCAG 2.2 added at A and AA, five of which nothing else here checked.

bin/rails runner bin/design/route-targets.rb > /tmp/targets.json
pw bin/design/wcag22-audit.js

2.4.11 Focus Not Obscured, 2.5.7 Dragging Movements, 3.2.6 Consistent Help, 3.3.7 Redundant Entry and 3.3.8 Accessible Authentication. (2.5.8 Target Size, the sixth, was already a design system rule.) wcag-audit.js runs axe against 2.1 and reported zero for months — accurately, against a version of the standard superseded in October 2023.

On its first run it found 2.4.11 failing on five screens: the scroll rail is fixed at the bottom of the window, and the browser scrolls a newly focused element only far enough to touch that edge.

Three of its own first findings were wrong, and each is written up in the file so the mistake is not repeated: a 2.5.7 click aimed at a fixed fraction of the rail landed on the thumb where the thumb filled 95% of the track; 3.2.6 measured the help link's position as a fraction of the page's focusables, so the same link on a long page and a short one looked like two places; and 3.3.7 was listed in the header and printed in the pass line with no check behind it at all. Every check here has since been proved to fire by planting the defect it is meant to catch.

address-audit.js checks that every screen collecting an address asks for it the same way.

bin/rails runner bin/design/route-targets.rb > /tmp/targets.json
pw bin/design/address-audit.js

Per field: the WCAG 1.3.5 autocomplete token for its role, a state chosen from a list rather than typed, a ZIP as text with a numeric inputmode rather than type="number", a label ending in the app's word for that part, and an accessible name at all.

Roles are read off the field's name, not from a list of screens, so a form that adds an address field is audited the day it is written -- the lesson from tooltip-audit.js, which carried a hardcoded page list and reported zero while there were fourteen. It found two things a manual sweep of the views had missed: a guardian ZIP on the family form, and a program ZIP still rendering as type="number".

autocomplete="off" is reported separately rather than failed: it declares an address belonging to somebody other than the person filling the form in.

A single freeform address input is a finding. Four models stored an address that way until 2026-09-01 and none does now, so one appearing again means a form has gone back to the shape the app moved away from. Currently 50 address fields, 0 findings.

row-actions-audit.js reads a table's actions column, which no other check here asks about.

pw bin/design/row-actions-audit.js

table-audit.js checks the visual weight of a row action and how many badges a row carries. Both can be perfect while the column itself is a mess: three buttons inline on one table and two behind a kebab on the next, 349px of actions column on one page and 60px on another, and — the case that gets reported — a column whose shape changes as you read down it, because the actions are picked by a case on the row's status.

It reports three failures — 3+ inline, no menu, varies row to row, mixed control heights — and one advisory, menu for 2 or fewer.

The advisory is advisory on purpose. design.md collapses a table when three or more actions are possible or when which actions exist depends on state. This reads one render of one seed, so it sees the count and not the variance: /items builds Delete, Deactivate or a disabled Deactivate from the item's state, and on seed data every row lands in the same branch. Reported as a failure it said /items was wrong, and /items is right. Six tables sit in that bucket and all six are correct; the case in the row partial is what settles it.

It keys off the actions column header, now that all 43 are the identical <th scope="col" class="text-right"><span class="sr-only">Actions</span></th>. Reading the last cell of every table instead treats a data cell of conditional links as a ragged actions column, which is how /events — which has no actions at all — was reported as varying row to row.

It counts the actions inside a closed menu, not just the visible ones. The panel is in the DOM while hidden, so it can be read without opening it — and without that a kebab row reads as "1 action", which made /distributions (three behind the menu, correct) indistinguishable from /items (two behind the menu, which is the defect).

wayfinding-audit.js asks whether a screen can be left, not just reached.

pw bin/design/wayfinding-audit.js

Every screen, in three roles, must be one of: a link in the sidebar or top bar, a page carrying a breadcrumb, or a page whose tabs link a sibling section. Anything else has exactly one way out -- the browser's back button -- and nothing else in this repo checks for that. It found ten, of which five were the entire reports section: every report is reached from the hub, none is in the sidebar, and none of them linked back.

It also reports a destination in two navigation surfaces. "Organization" sat in the sidebar's pinned footer and in the account menu behind the identical gate. Neither entry looks wrong on its own, which is precisely why it survived: every check the app has asks whether a page is reachable, and none asked whether it is reachable twice.

Three things it had to learn, each of which produced a wrong answer first:

  • Judge the URL it landed on, not the one asked for. Several sweep targets redirect, because route-targets.rb fills :family_id and friends with approximations. Comparing against the requested path reported /partners/authorized_family_members/new as orphaned when it had redirected to the families index, which is a nav root.
  • Page tabs count. They are lateral rather than up, but a tab strip linking a sibling section is still a way out -- /item_categories and /partner_groups are reached and left that way.
  • Signed-out and standalone screens are excluded, listed with reasons at the top of the file: the landing page, the legal documents (layout false), the account-request flow and Devise's invitation acceptance have no app chrome for a breadcrumb to sit in.

shell-first-audit.rb asks the question none of the others do. Every audit above answers is anything from the old system still present? This one answers is this built the way the new system builds things? — which is what a shell-first page fails: page header, card, no Bootstrap class, no console error, and inside the card a body nobody migrated.

ruby bin/design/shell-first-audit.rb

No browser, no server, no database; it reads the templates. Eight checks: a <table> that is not .data-table, a bare <hr>, float-*, a <br> standing in for a margin, four or more flat <p> label pairs where a <dl> belongs, a hand-written card header, button classes pasted inline, and a Font Awesome icon. Exits non-zero on any finding.

Read the narrowings before adding a check, because four of these eight started as false-positive factories and the reasons are the useful part. Mailers and static/ are skipped — HTML email needs tables, and the legal pages are standalone documents with their own <style>. A <br> inside an <address> is correct HTML, so only a doubled one or one right after a block closes counts; unnarrowed it reported 31 and 29 were fine. A detail pair must carry font-medium and there must be four, or a stat card's caption matches. And a hand-written card header only counts when the file actually contains a card, because a modal's header is the same markup — matching markup alone reported all fourteen modals in the app, and three of those keep their <dialog> in confirmation_controller.js, so no amount of looking for <dialog> would have helped.

It proves its detectors against a probe table first and aborts if one is wrong, the same as page-audit.rb and copy-audit.rb. That caught its own first bug: '\n' in a single-quoted Ruby string is two characters, so the card-header probe failed before the audit could report a false zero.

serve-mockup puts a design preview somewhere the person reviewing it can actually open.

bin/design/serve-mockup request-units
# => http://localhost:3000/request-units.html
bin/design/serve-mockup                  # lists all 44

Mockups are written self-contained so they open by double-clicking, and that is not enough: when the app runs in a container or behind a tunnel, the reviewer's browser reaches the dev server and nothing else, so a file:/// path shows them nothing. Three previews have been unviewable for this reason. The script copies the mockup into public/, where Rails serves it ahead of the router, refreshes mockup.css for the older ones that link it, warns if the served copy is not gitignored, and prints the URL to send.

sweep.js is gone. It was the older 56-path version, kept because it was quicker to run against a subset — but route-sweep.js performs every one of its checks, including the console-error capture that looked missing until you notice it is called errors there rather than consoleErrors. Keeping a hardcoded-list audit alongside its enumerated replacement was keeping the exact thing the replacement exists to remove, and the two would have drifted.

A page is clean when it has no leftover Bootstrap/AdminLTE or Font Awesome classes, exactly one <h1> and one <main>, no skipped heading levels, no unlabelled form controls, no buttons without an accessible name, no stylesheets from outside the app, no console errors, and is rendering in Figtree.

This complements the specs rather than repeating them. It catches what renders without raising — and the specs catch what a static walk cannot: a form whose fields end up outside the form, a Stimulus controller toggling a class that no longer exists, a confirmation that never appears. Neither on its own is enough; that is the lesson of this migration.

route-shadow.rb asks the neighbouring question and needs no browser either: is there a file in public/ that answers a route before the router sees it? Rails serves static files first, so public/vendors.csv answers GET /vendors.csv and the controller never runs — with a 200 and plausible content, so nothing looks wrong.

bin/rails runner bin/design/route-shadow.rb

It exists because of one afternoon. A CSV export request spec failed with three unfiltered rows and the wrong headers, while the controller, view model, concern, model, factory and spec were all byte-identical to before the change under investigation — and the cause was public/product_drive_participants.csv, untracked debris from a workspace rollback. bin/workspace-restore removes files a commit deleted and leaves untracked ones alone, which is correct because it cannot know which you meant to keep; this makes the consequence visible.

It says whether each shadow is tracked, because that decides what to do: an untracked one is debris and can be deleted, a tracked one is somebody's decision and the fix is a name — which is why every import template here ends in _template.csv. Only extensions the app actually routes are checked (csv html json pdf xml txt), and the count of candidates is printed either way, so "nothing shadows a route" cannot be confused with "nothing was looked at".

dead-routes.rb needs no browser and no server. It asks of every route whether the request would raise, and exits non-zero if any would.

bin/rails runner bin/design/dead-routes.rb

It decides with recognize_path rather than by reading each route's own controller and action, because another route can answer first — POST /users looks dead, and Devise handles it. When recognition raises, which it does for a route behind a constraint that needs a real request, it falls back to the declared target instead of skipping: skipping is how the first version lost /partners/donations, a dead route that had already been found by eye.

It reports shadowed routes separately: declared, but another route answers that path first. That is a different defect with the same symptom — /requests/partner_requests resolved to requests#show with an id of "partner_requests", because a second resources :requests declared a collection route below the first one's /requests/:id.

dead-code.rb asks the opposite question to dead-routes.rb: not routes with no code behind them, but code with nothing in front of it — no route, no render, no caller.

bin/rails runner bin/design/dead-code.rb

It covers controllers, actions, templates, partials, helper methods, services, queries, jobs, mailers, events, concerns, Stimulus controllers, importmap pins and public/. Read the exemptions at the top of the file before adding a check. There are thirteen and every one is a false positive that was reported, investigated and disproved — including the one that would have recommended deleting Figtree and Bootstrap Icons, which are named only by @font-face in the stylesheet and by nothing in Ruby or ERB.

Its findings are a list to work through, not a build failure. It exits non-zero anyway, so wire it into anything at your own risk.

responsive-audit.js visits every screen at 320, 375, 639, 641, 767, 769, 1023, 1025, 1280 and 1440, plus a landscape phone at 740×360, and reports what only goes wrong when a layout is squeezed: the page scrolling sideways, tap targets below the minimum, text below 11px, content clipped with no ellipsis to say so, a nav drawer that cannot be opened below lg, and fixed chrome covering more than half a short viewport.

The widths straddle the breakpoints on purpose. 639 and 641 are different layouts and only one of them ever gets looked at by hand; a layout that breaks usually breaks at the switch.

pw bin/design/responsive-audit.js
WIDTHS=320 ONLY=/items,/donations pw bin/design/responsive-audit.js   # while fixing

It swipes rather than calling scrollTo, and that is the whole reason it finds anything. The three obvious measurements all lie: documentElement.scrollWidth counts clipped content nobody can reach, body.scrollWidth stays at the viewport width even when the page does scroll, and window.scrollTo gets past overflow-x: clip when a finger cannot. Only a wheel gesture, followed by reading where the <h1> ended up, answers the question a phone user is asking.

Tap targets are WCAG 2.5.8's 24×24 with the exceptions applied — inline in prose, and the spacing rule — and the target is the control plus its label, because clicking a label activates its control. Without those, the first version reported 28 failures on the dashboard, every one of them fine, and 109 across the app of which most were select2's leftover 1×1 <select>.

The spacing rule asks whether a 24px circle on the target reaches another target's hit area, and "another" excludes the target's own ancestors and descendants. A container that encloses a control always intersects that circle, so counting it makes the exception unpassable: a truncated <td> takes tabindex="0" from clipped_text_controller and wraps its row's disclosure button, which is how fifty buttons came to be reported on /items/inventory at every width. That was this audit's 8/9 flicker, and it took five attempts because four of them compared counts.

The two geometric checks here both read positions that do not mean what they look like, and both were wrong the same way. The short-viewport one sums the vertical bands of anything fixed or sticky — but a sticky element with top and bottom both auto is pinned to a horizontal edge, and its band scrolls away with the content. The frozen actions column is exactly that, and unioned across eight rows it read as 186px of a 360px viewport on two admin pages, for a column that eats no height at all. Sideways-pinned elements are excluded now, and the run prints how many pinned elements it did consider — 30 across 146 page visits, all of them .table-rail at 24px of 360 — because "no page crosses the threshold" and "nothing was measured" are the same pass line otherwise. A four-page spot check convinced me it was the latter; the printed count corrected that in one run.

keyboard-audit.js checks what axe cannot: that tabbing never walks into something invisible, that everything clickable can be operated from a keyboard, and that nothing uses a positive tabindex.

pw bin/design/keyboard-audit.js              # 1280
WIDTH=375 pw bin/design/keyboard-audit.js    # and again on a phone

Run it at both widths. Below lg the sidebar is an off-canvas drawer, and a drawer moved out of sight with a transform is still in the tab order unless something says otherwise. That bug was invisible at 1280 and 27 tab stops deep at 375.

It knows that inert removes a subtree, that an aria-hidden scrim and a <dialog> backdrop are decoration rather than controls, and that the keyboard equivalent of a backdrop click is Escape.

form-validation-audit.js opens every new form, reads how its required fields are marked, submits it empty and reads what came back.

pw bin/design/form-validation-audit.js
ONLY=/items/new pw bin/design/form-validation-audit.js

Submitting empty is safe — it fails validation, so nothing is created — and novalidate is set first, because otherwise the browser blocks the submit and the server-side path, the one that has to work, is never exercised.

It knows three things a naive version gets wrong: a radio or checkbox group is marked on its <legend> and not on each option; a conditionally required field says so in words and correctly has no aria-required; and a form that accepts an empty submit had no errors to show, which is not the same as failing to show them.

undefined-classes.py reports every class token in the views and the helpers that the compiled stylesheet does not define. A class nothing defines renders as nothing, and this is the only check that finds one on a page no sweep visits.

python3 bin/design/undefined-classes.py

It proves its own extractor before reporting anything: Tailwind escapes . and : in selectors (.mt-0\.5, .focus\:ring-2), and a regex that does not unescape them calls every such utility undefined — the first version produced 186 findings, most of which were Tailwind working correctly. It also separates deliberate hooks from orphans, because a class can be meaningful without being styled: filterrific-periodically-observed and form-inputs belong to gems and must stay.

Reading app/helpers was added on 2026-09-08, and it needed two changes rather than one, which is the interesting part. Adding the glob found nothing: the "is this a deliberate hook?" test greps *.rb under app/, so the helper's own class: counted as a reference to itself and every helper-only token was filed as a hook. That test had always excluded .erb for exactly this reason — a view writing class="foo" is not evidence that foo is meaningful — and helpers write class attributes in a file type it does read. The files the script extracts from are now subtracted from that grep's matches. Widening what an audit reads can require widening what it discounts; the run in between reports clean and looks like proof.

status.rb reports which controllers render on a design system layout, resolving layout inheritance — and, since 2026-09-09, which views still use the old one.

ruby bin/design/status.rb

The view measure is inverted, and it is worth knowing why. It used to count views carrying design system markup, and that pattern was widened three times: 51 pages the first time, 17 the second, 3 the third, and the files remaining after that would have needed a fourth. Every one of them was already migrated. Three were bare f.input, which is the finished state because config.default_wrapper = :essentials means the wrapper supplies the classes; the rest carried a data-table cell class, a Stimulus controller, or a helper call whose argument was a variable rather than a symbol literal.

A positive-marker test cannot be finished: the design system gains vocabulary with every component, so the check is always one component behind, and every miss reports completed work as outstanding. The legacy vocabulary is closed — ADR 0011 deleted both frameworks, and they cannot gain new words. So the primary question is now "does this view still use the old system?", which is what "is the migration done" actually means, and the answer is stable.

Thirteen controls, one per legacy group — btn, card, form-group, the Bootstrap grid, Font Awesome, table-striped, d-none, label label-*, callout callout-*, data-bs-*, fa_icon — plus two that must stay silent, because the first draft flagged three migrated pages: card-surface is ours and \bcard\b matches the "card" inside it, and float-right is a Tailwind utility as much as a Bootstrap one, so its presence says nothing.

The positive count is still printed as an indicator, labelled as one: a four-line table row can be fully migrated and have nothing to mark. Files that cannot carry a marker for the system they are — the components in shared/essentials/, plus gem and framework partials — are reported separately rather than counted as failures.

copy-audit.rb reads the app's words and checks the things axe cannot: link text that says nothing out of context (WCAG 2.4.4), instructions that depend on position (WCAG 1.3.3), gendered and ableist wording, "please", and all-capital shouting.

ruby bin/design/copy-audit.rb
ruby bin/design/copy-audit.rb --verbose

It reads copy, not source, and it distinguishes a link from a heading — both of which it had to learn. A grep cannot tell a sentence from an identifier: the first he/she pattern matched render "organizations/header". And WCAG 2.4.4 is about link labels alone, so run over all copy the vague-text check reported all sixteen cards titled "Details", which are headings and perfectly fine. A short label carrying an aria-label that extends it is treated as named, which is the WCAG 2.5.3 way of keeping a compact visible label — and that exemption is itself probed, because it would otherwise be a hole.

Its probe table caught a bug review would not have: in a Ruby /x regex literal spaces are stripped, so VAGUE_LINK was quietly looking for clickhere. Every multi-word branch now uses \s+, and every branch has a probe. Break any check and the script refuses to run.