The Ministry of Justice's AI Action Plan for Justice is a three-year commitment to embed AI safely across the justice system. It took months of stakeholder engagement to write, and it makes a lot of promises. This post is about one of them. Action 1.3 commits us to making transparency a cornerstone of our AI approach, which in practice means reporting on progress in public.
You can discharge that with a RAG-status table in a PDF appendix, refreshed each quarter and read by almost nobody. We wanted something anyone could open and follow without a briefing, whether a minister, a probation officer or a journalist. The result covers 24 actions under three strategic priorities, together with the case studies delivering them, and it became the /our-work section of this site: the AI Action Plan Explorer. The rest of this post calls it the explorer.
The content isn't ours. Policy colleagues who own each action write the progress narrative, the programme's steering board reviews it, and the case studies come from teams across the Ministry of Justice, HMPPS and the wider justice system. Engineering's job was the site that presents it, and that site is the subject here.
The shape of the data
The work nests three levels deep: three strategic priorities, each holding a set of actions, and each action delivered by the case studies that move it forward.
The model, end to end
- 1.Strengthen Our Foundations
- 2.Embed AI Across the Justice System
- 3.Invest in Our People & Partners
e.g. Scale AI productivity tools across the system to free up time
Each level contains the next. An action’s stage is a roll-up of the case studies beneath it — the most advanced phase wins — unless its owner sets the stage by hand.
The diagram mirrors the explorer's model. The catalogue further down is a snapshot of the dataset as published.
Two vocabularies describe progress. At the finest grain, each case study moves through the unit's ScanPilotScale delivery model. Every action then rolls up to a single coarser label, so one scale can colour every surface. Early Stage, Progressing, Mature, or Complete.
The roll-up is a short cascade. A stage set by the action's owner always wins, and 18 of the 24 actions sit in that branch, because the people running each action have reviewed and chosen their own label. Failing that, we take the most advanced phase among the action's live case studies, so Scan lands the action at Early Stage, Pilot at Progressing and Scale at Mature. Failing that, the action's RAG status: blue reads as Complete, green as Progressing and anything else as Early Stage, so every action always carries a stage.
Those four colours are used identically on the action cards, the category bars, the filter pills and the detail pages. Here is the full catalogue, grouped by priority and tagged with the stage each action has rolled up to.
Data hierarchy
Maintained by the people who own the work
Public reporting goes stale when updating it is someone else's job, so the people delivering the work maintain the data themselves. Policy colleagues who own each action keep their progress narratives, stages and case studies current in an editor that lives on the private side of the publishing boundary described below, with the programme board's review cycle wrapped around it. That takes the developer out of the loop and removes the transcription step where errors creep in.
Direct access needs some constraints, and ours sit at the point of entry. Stages are chosen from a fixed vocabulary. Prose flows through a deliberately small markdown subset (paragraphs, bullets, bold and links), so nothing an author types can break a page. Downstream, components never touch the raw data: a typed access layer owns stage derivation, slug conversion and URL building.
Two pieces of plumbing sit underneath. The first is identity. Random IDs would churn on every update and turn every diff into noise, so each record was minted a deterministic UUID v5 from a fixed namespace and a typed key: action|<number> for an action, case-study|<number>|<name> for a case study. Once minted, an id is carried forward unchanged, so a study keeps it through a rename or a move between actions.
import uuid
# Same inputs, same ID, every time
NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
def make_id(*parts: str) -> str:
return str(uuid.uuid5(NAMESPACE, "|".join(parts)))Unchanged records keep byte-for-byte identical entries, so every published update is a clean diff in which only the changed records move, and a case study keeps its identity from one reporting cycle to the next. That lets the git history double as the audit trail.
The second is the publishing boundary. The public site is a fully static export with no live database, so publishing means taking a reviewed snapshot of the dataset and rebuilding the site as plain files. The editor sits on the private side of that line and is compiled out of the public build altogether: none of its code ships in the site you're reading, and the pipeline fails the build if an editor route or an editor API call ever appears in the export.
The page says so itself: the explorer is a Year One snapshot, covering 30 July 2025 to 30 July 2026 and accurate as of its publication date. Fresh snapshots will land with each reporting cycle.
Every view state is a URL
There is no hidden client-side state deciding what you're looking at; the hierarchy maps directly onto routes.
| URL | What you see |
|---|---|
/our-work | All three categories, with summary tiles, stage filters, and the full action grid grouped by priority |
/our-work/embed-ai | The grid narrowed to the Embed AI priority, with a wayfinding bar naming where you are and the way back |
/our-work/embed-ai/2-1 | Action 2.1 in full, with its article, its case studies, and its siblings as a "you are here" grid |
/our-work/case-studies#laurium | Every published case study in one flat list, with that one open |
Every state is therefore shareable. Copy the URL, send it to a colleague, and they see exactly what you see, down to which case study is open on the case-studies page. That one rides in the URL fragment so it survives a reload. Back, forward and bookmarks all behave, and search engines index each action on its own URL.
The toolbar is one deliberate exception. The stage filter and the search box stay out of the URL. They are reading aids, and pinning them there would mean every shared link carried someone else's filters. Which case study you have expanded on an action page stays local for the same reason; only the case-studies list writes that to the URL, because that page exists to be deep-linked.
The grid
The action grid had one hard requirement: anyone has to be able to read it at a glance. It is a plain grid of identical cards, and the effort went into the details.
Every card is the same size, on a responsive column count (one column on phones, two on tablets, three on desktop), with rows equalising to their tallest member whatever the content. Ordering is by action number only, with no size encoding and nothing else to decode. When the final row comes up short at desktop widths we pad it with empty tiles, so the block stays a rectangle instead of trailing off mid-row:
export const ACTION_GRID_COLUMNS = 3;
/** Empty tiles needed to finish the last row, keeping the grid rectangular. */
export function actionGridFillers(count: number): number {
if (count === 0) {
return 0;
}
return (ACTION_GRID_COLUMNS - (count % ACTION_GRID_COLUMNS)) % ACTION_GRID_COLUMNS;
}Each card states its delivery stage three ways: the tinted surface, the stage-coloured action number, and the stage named in plain text. A reader who can't perceive the palette loses nothing. Above the grid, a single control row holds the stage pills, which double as the legend, and free-text search across every field on the cards, including the names of the case studies inside them.
One-pixel seams
Every tile is separated from its neighbours by exactly one pixel, in both themes, at every zoom level.
Giving every tile a border fails in two ways once the browser rasterises it. Two adjacent tiles each drawing a one-pixel edge produce a two-pixel line where they meet, and fixing that with overlaps blurs the lines at fractional zoom, where the browser rounds each element's border independently. So the tiles carry no borders at all. They sit on the page's darker base layer, separated by one-pixel gaps, and the line between any two tiles is the page itself showing through. With neither neighbour owning the seam there is only one edge for the browser to round, so it cannot double.
The same discipline handles the joins between sections. Each block of the page pads its outer edge by half a pixel, so wherever two sections stack, their half-pixel edges meet in the same single-pixel seam as the gaps inside them. The line continues into the action articles, where each row of the actions navigator carries a one-pixel rail in the exact colour of that base layer, so the sidebar reads as part of the same continuous lattice.
The browser gets a whole number of device pixels per CSS pixel only at whole-number densities, such as 100% zoom on a standard or high-density screen. At 125% or 150% zoom on a standard screen (1.25× or 1.5× in the control below) it has to round. Each box edge rounds to the nearest device pixel on its own, and a border's width rounds down to whole device pixels, never below one. Those two rules drive the loupes below, where each square is one device pixel, and at every setting shown they reproduce what Chromium and WebKit draw. Bordered tiles meet in a line twice the weight of their outer edge. The gap is one span rounded once, so at a fractional density it lands on one device pixel or two depending on where the layout falls, and averages exactly one CSS pixel across positions.
One CSS pixel, rasterised
On the device-pixel grid
Border on every tile
Gap on the base layer (shipped)
- One square, one device pixel of line
- Where CSS places the line’s edges
| Seam | Borders | Gap |
|---|---|---|
| Between tiles | 4 device pixels2 CSS px | 2 device pixels1 CSS px |
| Between sections | 4 device pixels2 CSS px | 2 device pixels1 CSS px |
At 2×, bordered tiles meet in a line 4 device pixels wide, twice the weight of their outer edge. The gap draws 2 device pixels at every seam, exactly one CSS pixel.
| Position | 1× | 1.25× | 1.5× | 2× | 3× |
|---|---|---|---|---|---|
| 0 | 1 | 2 | 2 | 2 | 3 |
| ⅛ | 1 | 1 | 2 | 2 | 3 |
| ¼ | 1 | 1 | 1 | 2 | 3 |
| ⅜ | 1 | 1 | 1 | 2 | 3 |
| ½ | 1 | 1 | 1 | 2 | 3 |
| ⅝ | 1 | 1 | 1 | 2 | 3 |
| ¾ | 1 | 1 | 2 | 2 | 3 |
| ⅞ | 1 | 2 | 2 | 2 | 3 |
| Gap mean | 1 | 1.25 | 1.5 | 2 | 3 |
| Borders | 2 | 2 | 2 | 4 | 6 |
A bar chart with no chart library
Each category tile carries a stacked bar showing its actions by stage. It's one div per stage present, side by side in a flexbox:
<div className="flex h-[18px] w-full overflow-hidden rounded">
{stageBreakdown.map(({ stage, count }) => (
<div
className={cn("border-background border-r-2 last:border-r-0", STAGE_STYLES[stage].chartColor)}
key={stage}
style={{ flexGrow: count }}
/>
))}
</div>A charting library has to measure a pixel width before it can draw, so server-rendered HTML ships a grey skeleton that gets its colours only after JavaScript has loaded and a resize observer has fired. flex-grow performs the same proportional maths declaratively, in layout. The bar is fully coloured in the static HTML, on first paint, with JavaScript switched off entirely. There is no focus order to get wrong and nothing to recompute on resize, and the bar costs no JavaScript at all.
A two-pixel hairline in the page colour separates the segments, and that matters later: when a sibling tile is desaturated during a hover, hue flattens but greys survive, so the bar still reads as segments.
The interactivity is hand-rolled. Hover a segment and the tooltip emphasises that stage's count, via 20 lines of arithmetic that take the cursor's x-offset within the bar, walk the segments' proportional widths, and find the one you're inside. The segments themselves are decoration as far as assistive technology is concerned, and screen reader users get the same numbers as a plain sentence rendered alongside.
Interaction and spring physics
Hover
Every surface responds to the pointer, and each response is scoped to exactly what the pointer is over. Hovering a card brings that card forward: its stage tint steps up a notch, its secondary text sharpens to full strength, and a "View" affordance fades in. Hovering a category tile lifts it to a brighter surface and shows a summary of its actions and case studies. Move along its stacked bar and the breakdown emphasises whichever stage sits under the cursor.
Neighbouring cards are left alone. Dimming everything else while the pointer crosses a 24-card grid makes the whole page strobe, so dimming is reserved for the deliberate gestures described below.
Hover also has failure modes you only meet in production. Scrolling moves the page under a stationary cursor, which fires an "entered" event on whatever arrives beneath the pointer, with the matching "left" event never coming. Unhandled, that's a highlight stuck on through no user action. So the action grid drops its tooltip the moment the page scrolls, and leaving either grid altogether resets every hover state. The category tiles' spotlight is handled differently: WCAG 1.4.13 asks that content revealed on hover persist until the pointer leaves or the reader dismisses it, so scrolling leaves it alone and Escape clears it. Touch taps synthesise the same phantom events, so touch devices skip hover semantics entirely, and everything a hover reveals is also on the cards and pages themselves.
The tooltip that follows your cursor
The tooltip trailing your cursor is a second-order spring system. Its position obeys
where is the natural frequency and the damping ratio. In plain terms, the tooltip behaves like a small weight pulled towards the cursor by a spring and steadied by friction. We run stiffness and damping at unit mass, which gives : underdamped, so it trails the cursor as if it had mass, but close enough to critical damping that it settles in a single pass with almost no overshoot. Both axes run identical springs, so the tooltip moves as one object.
The practical engineering sits around it. Edge detection runs on the raw cursor position rather than the sprung one, because the decision to flip has to be made from where the cursor is now. If the tooltip would overflow its container it flips to the cursor's other side, and the flip offset is itself spring-animated (, , so ) so the tooltip slides across the cursor instead of jumping. That check needs the tooltip's rendered size and its container's bounds on every mouse move, which naively forces the browser to recalculate layout. Ours coalesces those reads behind requestAnimationFrame and caches the bounds in a resize observer, so a mousemove costs almost nothing.
One shared, domain-free component serves both the category tiles and the action grid. On touch devices it doesn't render at all. For readers who prefer reduced motion both springs are bypassed and the tooltip snaps directly to the cursor.
Highlighting across the page
Hover a stage pill and every card of another stage fades and desaturates, along with the sibling pills. Hover a category tile and the other two tiles and every card outside that category do the same. Hovering the legend previews the filter.
The coordination is a single piece of shared state tracking two values, which category is in focus and which stage. The category can come from a hover or from the URL itself, since standing on a category page holds that category in focus; the stage comes from a hovered or clicked pill, or from the bar segment under the cursor on a category tile. Every visual component subscribes and checks whether it matches. Non-matchers transition opacity and filter, properties the browser composites on the GPU without recalculating layout, so 24 cards, three bars and the pill row can all transition at once without dropping frames.
How hard that fade goes depends on who is driving it. A hover is transient and under the reader's control, so it may dim hard, down to 40% opacity and fully desaturated. A clicked filter persists after the pointer leaves, and text left that faint indefinitely would be unreadable for low-vision readers with no way to know why. So nothing persistent ever dims. A clicked stage pill filters the grid down to the matching cards, the category you are standing in is marked with a ring on a full-strength surface while its siblings keep their colour, and the pill row that controls the filter stays fully legible throughout.
Scroll choreography
The explorer's navigations are in-place content swaps under persistent chrome. Hero, category tiles and toolbar hold still while the grid beneath them changes. Making that swap feel continuous took more work than anything else on the page, because destinations differ in height and structure, and the browser's default handling of both works against you.
Scrolling to an anchor at click time races the route change. The anchor's position moves with the new page, so the landing offset jitters depending on whether the destination was already cached. Navigating from a long page to a short one while scrolled deep is worse, because the browser clamps your scroll position the instant the new content commits. Three fixes, each aimed at one specific frame:
- Defer the scroll to the commit. A click only registers an intent, capturing the anchor's offset and the reader's position while the outgoing page is still laid out. That registration is consumed after the new page exists in memory but before it's drawn, the one moment when measurements are fresh and nothing has been shown. Unconsumed registrations expire, so a click that never navigates can't scroll some later, unrelated one.
- Floor the document height. Before the swap, the page's minimum height is pinned at its current scroll height, leaving the browser nothing to clamp when a shorter page lands. The floor releases once the landing scroll settles.
- Re-seat, then glide. In that same pre-draw frame, the reader is instantly restored to the same distance from the anchor they had when they clicked, which makes the content swap invisible. Only then does the smooth scroll play.
Readers who prefer reduced motion get an instant jump instead of the glide.
Morphing between routes
The View Transitions API is the browser's built-in mechanism for animating between two DOM states. Call document.startViewTransition(callback) and the browser snapshots the page, runs your update, snapshots the result, and animates between the two. Any element carrying a view-transition-name present in both snapshots is animated from its old box to its new one, with its two snapshots cross-fading inside the moving box. Every action card names itself:
<div style={{ viewTransitionName: `vt-action-${action.slug}` }}>Timing is the hard part. The API needs to know when the new page is ready, but a client-side navigation is asynchronous: the router returns immediately, and the route commits some time later. Because every view state is a URL, a navigation is a complete description of the visual change, and the scroll choreography above had already built the hook we needed, code that runs in the commit frame of every explorer navigation. One resolver connects them (abridged):
export function navigateWithViewTransition(navigate: () => void): void {
if (!document.startViewTransition || prefersReducedMotion()) {
navigate(); // progressive enhancement: plain navigation
return;
}
// `settle` is called from the commit-frame effect, once the incoming page
// exists and has been re-seated at its landing scroll position.
const committed = new Promise<void>((resolve) => { settle = resolve; });
document.startViewTransition(async () => {
navigate(); // router.push(href)
await Promise.race([committed, timeout(1500)]);
});
}The commit-frame code performs the deferred landing scroll and then calls settle(), so the browser snapshots the incoming page after it's already resting at its destination. That ordering changes the scroll behaviour too. During a view transition the glide is skipped in favour of an instant jump, because the morph is doing the moving. Both snapshots capture resting positions, and the browser animates the difference.
The result, with tiles that persist across routes:
- Explorer to category: the surviving cards glide from their grouped positions into the narrowed grid, and the category's group heading morphs into the wayfinding bar's title.
- Category to action: the grid morphs into the sibling grid, and the clicked card glides into its ring-marked "you are here" tile.
- Action to action (prev/next, sidebar, breadcrumb): the sibling grid holds still while the ring hops from card to card.
The demo below keeps its state in this page's query string through nuqs, and its scene only ever changes inside startViewTransition, so a click, back, forward and a hand-edited query string all animate the same way. Each card carries a view-transition-name, which is all the browser needs to move it from its old box to its new one. Switch "Names" off and the whole frame cross-fades as a single picture. "Slow" and "X-ray" show the boxes the browser animates and the snapshots inside them. The update here is synchronous, so the callback commits it with flushSync. The explorer's route change is asynchronous, which is why it needs the settle handshake above.
- 1.1Complete
- 1.2Progressing
- 1.3Mature
- 1.4Progressing
- 1.5Progressing
- 2.3Early Stage
6 actions, by number.
startViewTransition.Every failure mode falls back to a plain navigation, which is what lets a government service ship a browser API this new. Without startViewTransition we navigate normally. Under reduced motion we navigate normally, guarded in code and again in CSS. If a snapshot contains a duplicate name, the browser skips the animation and the navigation still lands. Modified clicks fall through to native behaviour, so Command-click still opens a new tab. The whole system is about 90 lines with no dependencies beyond a reduced-motion check, covered by its own end-to-end tests, including one asserting the API is never invoked for reduced-motion readers.
The action page
Every action has its own page at /our-work/[category]/[action]. Because the site is a fully static export, all 24 are pre-rendered at build time by walking the dataset:
export function generateStaticParams() {
return actions.map((a) => ({
category: strandToSlug(a.strand),
action: actionNoToSlug(a.actionNo),
}));
}Each page is a proper article, with a table of contents synthesised from the data itself: Overview, then Year 1 progress, Year 2 priorities, Case studies and Areas for future exploration, each section appearing only if the action has content for it. The prose is the policy owners' own words, flowing through the constrained markdown subset from earlier.
For the commitment itself the page links to the action's own text in the published plan on GOV.UK, so there is one copy of each commitment and nothing to drift.
There are several ways to orient yourself on the page. A context strip names where you are ("Action 4 of 12 in this category") and the ways back out. A sibling grid renders every action in the category as the same stage-tinted cards, with the current one ring-marked. There is also a breadcrumb with dropdowns for hopping sideways, prev/next controls walking the category in order, and an "All actions" navigator in the sidebar, mirrored inline on smaller screens. The same four stage colours are used throughout.
Pre-rendering everything buys two more things. Each action gets its own Open Graph image, generated at build time, so a shared link to Action 2.1.3 previews as that action, with its own number and title. And because each page is a real, crawlable URL listed in the sitemap, a single action can be found from a search engine without someone having to send you the link.
Case studies in one place
/our-work/case-studies gathers every published case study across all 24 actions into one flat, action-ordered list. It's an accordion styled like a table, with columns for Case study, Action, Phase and ATRS.
Each row's expand button stretches across the row visually, but the action number inside it is a real link to its action page, never nested inside the button, which is the classic accessibility trap accordions invite. The link's hit target is enlarged beyond its visible text to the 24-pixel minimum WCAG 2.5.8 asks for, without an overlay that would swallow the row-toggle clicks around it.
The open row lives in the URL (#laurium), so a specific case study is deep-linkable and survives a reload. On arrival, and on any later change to the fragment, the list opens that study and holds it under the header until the collapsing row above it and the browser's own fragment scroll have both finished moving. Clicking a row never scrolls the page; only a change to the fragment does. The server-rendered HTML is the same list with every row closed, because the open study is client state that starts empty, and each row already carries its slug as an id, so the browser lands on #laurium before any script runs.
The ATRS column
The Algorithmic Transparency Recording Standard is the UK government's cross-departmental standard for publishing how public-sector algorithmic tools work: what data they use, how they're overseen, and what they can and can't decide. For a unit whose founding plan promises transparency as a cornerstone, publishing ATRS records is part of how it meets that commitment.
Every case study row states one of three ATRS positions: Published, shown as "ATRS" with a tick and linked to the live record on GOV.UK where we hold one; Pending, where a return is in progress; or N/A, for programmes, partnerships and tooling outside the standard's scope. A blank cell can therefore never be misread as "no record needed". Three records are live today: the Violence in Prisons Estimator, the Delius Contact Log Semantic Search (semantic search over probation case notes), and the OPG Investigations Assistant, with more to follow as returns complete. Two smaller details in the badge: "ATRS" expands for screen readers to its full name, and the published-state tick is decorative, its meaning carried by the words beside it.
The case studies behind that column come from teams right across the Ministry of Justice, HMPPS and the wider justice system: prison-safety analysts, probation digital teams, the partnerships behind LawtechUK and the AI Growth Lab. The standard itself is stewarded centrally across government.
Search
The explorer is generated from structured data, and the site's search only indexes prose pages, so by default it has nothing to harvest from the explorer. The CommandK palette would know this blog post inside-out and have never heard of Action 2.1.3. So the dataset builds its own search index. One entry for the explorer, one per category, one per action with headings mirroring the real page sections so results deep-link to the right anchor, one for the case-studies view. Search "Delius Contact Log Semantic Search" or "transcribe" and the palette lands you on the action doing the work.
Two things bit us here. The search library keys each indexed section as ${documentId}-${n}, so with bare URLs as document ids, action …/2-1's first section would collide, character for character, with action …/2-1-1's page. That is what hierarchical action numbers plus URL-safe dashes get you: a collision you only meet in a government numbering scheme. Document ids get a :page terminator so neither id can be a prefix of the other. And the case-studies entry indexes each study's name and the action it belongs to, nothing more, since every study's full prose is already indexed against its action page, and duplicating it would roughly double that entry's share of the index every reader downloads, for no benefit.
Accessibility, measured
Accessibility hasn't needed its own section until now because it has been part of every decision above: stages never encoded by colour alone, the hover-versus-selected fade rules, a bar chart that paints before JavaScript exists, reduced-motion paths through every spring, scroll and morph, real links never nested inside buttons, and hit targets that clear the 24-pixel minimum. The foundation underneath is semantic HTML. Every tile is a real link, every filter a real button with its pressed state exposed, filter results are announced to screen readers, and focus is visible everywhere. When an in-place navigation removes the control a keyboard user just activated, focus is moved to the nearest sensible element so it doesn't fall back to the top of the page.
The part worth copying is the measurement. The stage palette was validated by driving the live rendered pages with Playwright, sampling each surface's true colours through an on-page canvas, compositing the dark theme's translucent tints over the real page background, and computing contrast the way the standard defines it. Every stage text-on-tint pair clears the required 4.5:1 with room to spare in both themes: the weakest measured 6.84:1, and seven of the eight clear the stricter 7:1 line. We also measured how the palette survives colour-vision deficiency, simulating protanopia, deuteranopia and tritanopia at full severity with the Machado–Oliveira–Fernandes model. Where two hues converge under simulation it doesn't matter, because, as above, no stage is encoded by colour alone. A reader with total colour blindness, or a monochrome display, gets every piece of information the palette carries.
WCAG 2.2 conformance checks run as end-to-end tests across the explorer's routes, in both themes, at desktop and at a 320-pixel viewport, the width the page must reflow to for readers zoomed to 400%. A regression fails the pipeline. Even this post's interactive demos have their own end-to-end tests, including a JavaScript-disabled pass, since the demos are the part of this page most likely to break on an older device.
None of that replaces an outside view. User Vision audited a pre-release build of the site and reported in September 2026: 19 findings, of which two were high, five medium, six low, four observations and one positive. Both highs were in the shared chrome around the explorer, in the search palette, where keyboard activation opened the wrong result, and in the navigation drawer, which became unusable at high zoom. Neither had shown up in our own testing, which is the point of paying someone else to look. All 18 actionable findings have been fixed and verified in a browser, the automated suite still reports no WCAG 2.2 A or AA violations, and testing with people who use assistive technologies is the next step.
What we'd do again
Putting every view state in the URL did more work than any other decision. It gave us sharing, bookmarks, back and forward and crawlable action pages for free, and when the View Transitions API needed to know what a navigation changed, the URL already said. With hidden view state that would have meant restructuring the app.
Each time we tried the platform before a library it had what we needed: flexbox for the bar chart, the View Transitions API for the route morphs, one-pixel gaps for the seams.
Most of the explorer's feel comes from doing each piece of work in the right frame: measuring before the swap, restoring before the first draw, and gliding, or letting the morph move things, afterwards. There is one pre-draw moment per navigation, and the scroll and transition code exists to use it.
On accessibility, the useful discipline was measuring: contrast computed from the live page, colour vision simulated, conformance tests in the pipeline, and an external audit whose findings we closed.
What's next
The plan runs for three years, and this explorer is how we report on it in public. Each reporting cycle will publish a fresh snapshot, a clean diff because record identities never change between them, and we are working out how to visualise that change and how the stage distribution shifts over time. More ATRS records will be published as returns complete.
The explorer is at /our-work. If you want to work on AI in public services and care about the web engineering underneath it, we are hiring.
Last updated on