Pilot 5 · Mobile app · Cash forecasting for one self-employed person

Rill

It reads an account so it can refuse to move anything in it.

Six screens, three edge states and thirty-seven public states, built as a running React Aria application rather than a picture of one. It answers one question — what may I spend — and it answers it by printing the subtraction, not the conclusion. This page reads down the ledger the product forecasts across: from the morning the account was connected, through the figure it prints today, to the day two weeks out where the projection crosses the line the user drew. Every quantity below was read off the build or off a file in the repository.

Role
Product design and front-end build
Surface
Mobile · 390 × 844 primary, 430 × 932 sanity
Component system
react-aria-components 1.19.025 components across 8 import sites in src/, of which 8 are the machines the spec binds
Slice
6 screens · 3 edge states · 37 public states · 15 QA‑only
Measured
55 renders · 154 Playwright tests · 52 Storybook stories · 28 axe runs
Accepted
Gate C passed at round 1 — the first of the five to do so. 0 upstream repairs spent

Part one The ledger — one account, fourteen days, and the figure at the bottom of it

2 MAR 09:14 1. Connection
Brindle Savings

Nothing in this app can send, transfer or spend

Rill is a cash-forecast app for one self-employed person. It connects to a single account, reads the balance and the payments already scheduled on it, and prints one figure: what is safe to spend between today and the end of a fourteen-day horizon. It is not a budgeting app, not an invoice book, not a tax calculator, and it contains no chart of any kind — no arc, no ring, no bar, no sparkline. The timeline is the visualisation.

Every name, figure, date, counterparty and institution in this project is invented. Rosa Kilbane, Brindle Savings, Alder & Frame Studio and INV-2026-014 do not exist; no real institution, person, rate, jurisdiction or product is named anywhere in the build. Rill states no tax rate, no threshold, no deadline and no obligation, and the words owe, liability and return appear in no copy slot. Nothing on this page or in the product is financial advice.

The first screen is a refusal, and the refusal is the product

Screen 1 states what the app will not do before it asks for anything. The support line under the title is the sentence the whole build is arranged around, and the first of four rationale rows opens onto a third line that closes the loophole in it.

Rill reads your account so it can show you what is already committed. It never moves money.

Nothing in this app can send, transfer or spend. The only figure Rill changes is the one you set aside yourself, and that stays inside your own account.

src/screens/Connection.tsx:96–98 · :37

Two of the four rows are constraints rather than features — Read-only access and Disconnect any time — and the retention row states its window as prose rather than as a setting: a reading is a balance and a date. Rill keeps the last 90 days of them so it can show you what changed, and drops the rest. That 90 is a single constant, RETENTION_DAYS, and it is a constant because the static scan described in ⑤b caught it written out twice on its first run.

The wait that follows names its three stages — Reading your balances, Reading your scheduled payments, Building your timeline — instead of spinning. The counter that would tell a user they are on step 1 of 3 exists only as an accessible name; no step counter is painted anywhere in Rill.

£0.00 Moved by the product,
in any state
20 MAR 09:12 2. Home
Fri 20 Mar 09:41

One figure, and the subtraction beneath it

Home carries £1,240.00 at 48 px in IBM Plex Mono, and directly under it — not on a second screen, not behind a disclosure — the three numbers it came from: balance £4,182.60, committed −£2,192.60, reserve held £750.00. The subtraction closes exactly, and a reader can check it without leaving the screen.

Safe to spend is defined, not asserted. It is the lowest projected available balance across the horizon, where available is balance minus reserve minus everything committed to that date. The low point is £1,240.00 on Friday 3 April, which is the figure Home shows and the reason it needs no disclaimer: it is not a prediction of behaviour, it is the floor of an arithmetic printed on screen 3.

The row beneath the figure — 8 committed payments to 3 April — is itself the control that opens that arithmetic. The header row above it, Last reading 09:12 today · Brindle Savings, is the control that opens the record of where the numbers came from. Both are whole 358 × 44 rows; neither is a chevron a thumb has to find.

The one panel that exists to say a number is missing

Under the composition sits a block headed NOT COUNTED: £1,850.00 from Alder & Frame Studio is 14 days overdue. It is not in any figure above. An invoice fourteen days late is exactly the money a forecast is tempted to count, and counting it would make the forecast better than the evidence. Rill names the exclusion in words on the screen the exclusion affects.

EX-01 Today, and the arithmetic under the figure state audit capture · 1170 × 2532 · 390 × 844 at dSF 3
Rill's Today screen: safe to spend £1,240.00, a derivation row reading 8 committed payments to 3 April, a panel showing balance £4,182.60, committed to 3 April −£2,192.60 and reserve held £750.00, then a NOT COUNTED panel naming an overdue invoice of £1,850.00.
home-defaultThe hero, the derivation row, the composition panel and the exclusion, in one 844 px screen — nothing here is behind a disclosure. The reserve row carries a lime mark and the lime appears nowhere else on the screen. The figure is set in mono and the prose in sans, and that split holds across all six screens: mono carries what the machine produced, sans carries what the product says.
£1,240.00 Safe to spend
to Friday 3 April
20 MAR → 3 APR 3. Cash Timeline
14 days

Nine rows, eight payments, and one word between them

The timeline is a single stream bucketed by week, with a right-aligned total on every bucket header — −£62.60, −£1,052.00, −£1,078.00 — and each of those totals is the sum of the rows drawn under it. The three add to £2,192.60, which is the committed figure on Home. Every row carries the balance it leaves behind, so the last projected balance, £1,990.00, is safe to spend plus the reserve.

The header counts 9 payments while Home says 8 committed payments, and that is not a contradiction: nine rows are painted and one of them, the inbound INV-2026-014, is marked not counted. Two different quantities, distinguished by the word committed — which is also the name of the segment the reading is on. Both are derived, and the agreement suite asserts each against its own population rather than against the other.

The rill, which is what the product is named after

A 3 px rule runs down the 44 px gutter beside every row, in lime for as long as the projection stays above the reserve. It is not a chart and it encodes one fact only. On this page the same rule runs down the left of every entry, and it changes colour at the same point the product's does.

430 is a reflow, not a scale

The sanity viewport widens the gutter 16 → 20 and the content 358 → 390; no type size changes and no region is added. Measured in Chromium on the running build: the timeline's scroll region is 618 px at 390 and 706 px at 430, so 4 rows sit wholly inside the fold at 390 and 6 at 430 — and Card repayment, cut by the fold at 390, is whole at 430. The segmented control's two labels measure 177.5 and 178.5 at 390 against 193.5 and 194.5 at 430.

EX-02 The committed stream, at both widths state audit captures · 1170 × 2532 and 1290 × 2796
Rill's Timeline at 390 by 844: a Committed / All segmented control, a summary panel, then weekly buckets with right-aligned totals and rows each carrying a running balance, beside a lime rail.
timeline-committed · 390 × 844Four rows sit wholly inside the fold; the fifth is cut.
The same Timeline screen at 430 by 932, showing two further payment rows inside the same fold.
timeline-committed · 430 × 932Six rows, and the row cut at 390 is whole. Same type sizes, wider gutter.
Scroll region height, measured in Chromium
618 px · 706 px
Payment rows wholly inside the fold
4 · 6
Segmented control labels
177.5 / 178.5 · 193.5 / 194.5
Type sizes changed between the two
0
£2,192.60 Committed
across three buckets
27 MAR 4. Invoice Risk
INV-2026-014

The money that is deliberately not counted

One invoice, £1,850.00, fourteen days overdue. The screen states its status, the obligation, both dates, the evidence and then the actions — in that order — and the expected-payment panel derives its date from the client's own history rather than asserting one: Both invoices Alder & Frame Studio has settled arrived 21 days after the due date. Under it, in risk-text: It is not counted in your safe-to-spend figure until it clears.

A destructive action that admits it changes nothing

The overflow menu offers two actions and names the destructive one in words rather than in an icon. Its confirmation is the sentence most products would not write, because it removes the reason to feel anything about pressing the button:

The invoice and its expected payment are removed. Your safe-to-spend figure does not change, because this invoice was never counted in it.

src/screens/InvoiceRisk.tsx:228–230

The cancel is labelled Keep the invoice, not Cancel, and it takes initial focus. The destructive item is set in risk-text and is not a filled button — there is no filled destructive control anywhere in Rill, and a Playwright test asserts that across the invoice and timeline surfaces.

One thing the flow refuses to be

The Invoices tab opens on a single invoice rather than a list. The critical slice is recovering one overdue invoice, so a list view is out of scope by decision: STATE_INVENTORY.md enumerates no list state, and the case study is not going to claim one.

EX-03 The invoice, and the write-off that claims nothing state audit captures · 1170 × 2532 · detail at 1:1
Rill's Invoice screen: a 14 days overdue chip, the title £1,850.00 from Alder and Frame Studio, reference INV-2026-014, issued and due dates, Send a reminder, Mark as paid, Set money aside instead, then an Expected Friday 27 March panel.
invoice-overdueStatus, obligation, both dates, evidence, then actions.
invoice-writeoff-dialog · at twice the product's own scaleCropped in CSS from the audit capture at device-relative 16, 284, 358 × 276. The capture is deviceScaleFactor 3; the image is drawn here at 780 page pixels for 390 product pixels — two-thirds of the file's own resolution, so nothing is upscaled and no new image was produced for it.
£1,850.00 Overdue 14 days
in no figure above
2 APR Edge 2 · negative forecast
3. Cash Timeline

The day the projection crosses its own line

A ninth committed payment arrives in a later reading — equipment insurance, £1,610.00 on 30 March — and the projection falls below the £750.00 reserve on Thursday 2 April at £476.00, ending the horizon £370.00 under it. Three things change and one thing deliberately does not.

The gutter rail gains a crossing marker at 2 April and the lime stops there. An advisory band sits under the summary carrying one sentence and no figure of its own. Home's hero reads £0.00 and its derivation row becomes 9 committed payments take you £370.00 below your reserve.

The figure is not recoloured and no error icon appears. A correct number in an alarm colour teaches a user that the product is broken; the hero stays in ink, the summary panel stays unrecoloured, and the sentence carries the alarm. £0.00 is also a floor, not a negative: the arithmetic is £4,182.60 − £3,802.60 = £380.00, which is £370.00 short of the reserve, and the hero prints £0.00 rather than a negative number the user cannot spend.

The decline leaves the marker, because the marker is data

Show the payments after 2 April filters the stream to the rows from the crossing onward and states in words how many were hidden. Not now closes the band for the session — and the crossing marker on the rail stays, because the band is advice and the marker is a fact. Reopening the screen brings the band back.

EX-04 The negative world, unrecoloured state audit capture · reached by the seeded world its inventory row names
Rill's Timeline in the negative world: a summary reading committed £3,802.60, safe to spend £0.00, reserve £750.00, then a red advisory band reading Your committed payments take you £370.00 below your reserve by 3 April, with Show the payments after 2 April and Not now.
timeline-negativeOne sentence, two controls, and a summary panel that keeps its own colours. risk is used for exactly two things in the whole product — a late obligation and the date a projection crosses a line — so the band reads as information rather than as an alert style. The two controls stack rather than sitting side by side: 326 + 80 px of control does not fit a 326 px band, and the band is in flow, so growing costs no rectangle.
−£370.00 Below the reserve
at the horizon
3 APR 5. Tax Buffer
the horizon

The consequence is printed before the button

Both affected balances are on screen before the amount is asked for. Choosing £400.00 writes two before-and-after rows — £1,240.00 → £840.00 and £750.00 → £1,150.00 — and then a sentence that stops the user believing the wrong thing about what they just did:

Your lowest projected balance is still Friday 3 April. Setting money aside moves the line, not the forecast.

src/screens/TaxBuffer.tsx:218

The confirmation replaces the selector in place: a stamp, 20 Mar 2026 09:43, the two figures that changed, and the sentence that makes this screen consistent with screen 1's promise.

It stays in your Brindle Savings account. Rill has not moved anything; it has changed what it counts as spendable.

src/screens/TaxBuffer.tsx:111–113

There is no confetti, no success animation and no tick that fills. Back on Home both figures carry the move and the balance and committed rows are unchanged, because only one of the three inputs moved.

A spec that could not be satisfied, resolved in favour of the presses

SCREEN_SPEC.md §5 asks for a £25 minimum, a £25 step and a maximum of "the whole of safe to spend", £1,240.00. Those three cannot all hold: 1,240 ÷ 25 = 49.6, so the highest amount the stepper can reach is £1,225.00. The grid wins, because it is what a user's presses actually produce, and the disabled-step reason names £1,225.00 rather than a figure no press can reach. A test drives the field to 99999 and asserts it settles at £1,225.00 with impact rows of £15.00 and £1,975.00.

EX-05 Reserve, with both figures before and after state audit capture · 1170 × 2532
Rill's Reserve screen: safe to spend £1,240.00 and reserve £750.00 either side of an arrow, four preset chips with £400 selected, a stepper reading £400.00, a WHAT CHANGES panel with before and after rows, and a primary reading Move £400.00 to reserve.
reserve-defaultTwo balances, four presets, an editable stepper, and the consequence above the primary. The primary names the amount rather than saying Confirm, so the last thing read before the press is the thing that will happen. This is also the screen carrying the build's weakest moment: the value field drops from 48 px to 20 px at £999.99, which prevents an overflow but resizes type under the user's fingers.
£840.00 Safe to spend after
reserve £1,150.00
18 MAR 07:04 Edge 1 · stale data
6. Sync Recovery

Two days old, and the record says so three times

When the newest reading is 18 March and the clock reads 20 March, a 44 px band docks above the tab bar naming the source and the age. Every figure keeps its value and its position. Nothing is blanked, nothing is greyed, no dialog opens — and the band has no dismiss control, which makes it the one non-dismissible surface in the product. A forecast may not let its user hide the age of the data it rests on.

The data screen states the consequence in the user's terms rather than the network's:

These figures are 2 days old. Anything you have spent since 18 March is not in them.

src/data/derive.ts:411

Underneath, the sync record has three rows, because three things happened: a session ended by the source at 07:12 with No reading, the 18 March reading, and the original connection on 2 March. A fourth row describing a 09:12 reading was in the delivered source and was removed — in this world that reading never arrived, and a record listing it above a panel saying it did not is the ⑤b defect with a list instead of a badge.

The third edge is this one with the failure surfaced. Try again succeeds at 09:44, the failed attempt stays in the record, and every figure updates. Enter a balance by hand opens a numeric field pre-filled with the last good figure, and a figure entered that way is marked entered by hand beside every place it appears.

EX-06 Two days old, and the three-row record state audit capture · 1170 × 2532
Rill's Data screen in the stale world: Brindle Savings current account 4192, last reading 18 Mar 2026 07:04 in red, a sentence saying the figures are 2 days old, Check for a new reading, Enter a balance by hand, and a sync record of three rows.
sync-staleThe last good reading stays on screen above the failure, and the record has as many rows as things happened. The empty band under the third row is a recorded dead-space finding of 161 px against a 126.6 px limit, and it is accepted rather than filled: adding a fourth row would be inventing an event. It is also where the build pack and the build disagree — the pack predicted 190 px at 430 and the build measures 249, because this world's record has three rows rather than four.
2 days Reading age
3 rows in the record
0 ITEMS 3. Cash Timeline
the empty range

An empty range that routes out instead of apologising

The one empty state in the slice does three things in order: it says what is true, it says what Rill does have, and it offers the one control that leads somewhere.

Nothing scheduled in this range.

Rill has readings from 2 March. Switch to Committed to see the eight payments already scheduled.

src/screens/CashTimeline.tsx:298 · src/data/derive.ts:380

Above it sits the only raster in the product's own UI: a closed cloth-bound ledger, unbranded, carrying no readable text, no lettering, no numerals, no hand and no coin. It is a photograph of the object the product is a version of, not an illustration of a shrug. The count in that sentence is written in words by a derived helper, so eight cannot drift away from 8.

Why no text run in this build is unmeasurable

axe cannot compute a contrast ratio for a run whose ancestor carries a background image, and reports it indeterminate — a report of zero violations that leaves runs unmeasured has not measured them. Rill sets no text over a raster in any state: the ledger photograph carries none, and the paper grain is a 3 % pseudo-element under the whole frame rather than an ancestor of anything. The audit's contrastIndeterminate count is 0 across all 55 renders, and that is a measurement rather than a waiver.

EX-07 The one raster state state audit capture · 1170 × 2532
Rill's Timeline with the All reading selected and nothing in range: a photograph of a closed green cloth-bound ledger, the heading Nothing scheduled in this range, a sentence pointing to the Committed reading, and a Show committed payments control.
timeline-emptyThe summary panel above it keeps its figures — the range is empty, the account is not. The header reads 0 items rather than 0 payments, because under the All reading the stream counts more than payments. The raster is one of two in the whole product and the only one a user sees; the other is a paper grain at 3 % under the frame.
0 items In range · 8 payments
one segment away
27.12 s one take
Playwright

The same morning again, walked once, without a cut

Everything above is a frame held still. This is the morning at its own pace: 27.12 seconds, one take, no cuts and no speed change, recorded straight from the running Vite build at the primary viewport. Every step goes through a control the product exposes, resolved by role and accessible name, using the same drivers the acceptance suite uses. There is one navigation in the whole take — the first goto('/') — and no seed.

The path is T1, T3, T4, T5, T6, with one detour into the write-off dialog, because that dialog is the product's argument in a single sentence. The pauses exist so the screens can be read — the only thing directed here is the pace.

What the take does not contain, and why

No edge state appears. All three live in a seeded world, and there is no forward path to any of them from the healthy one: the copy deck stamps every one of them before the session opens, so no sequence of presses reproduces them. Reaching one would mean a second navigation, which is a cut. T2 / R1 — Home to the data record and back — is left out for length; it is covered by the acceptance suite and by EX-06. Saying so here is cheaper than a take that pretends otherwise.

The frame this file reports is the frame it contains

The two SaaS takes in this portfolio declare a video size equal to their viewport. The two earlier mobile takes declare 780 × 1688 against a 390 × 844 viewport — and Playwright only ever scales a page down to fit a requested size, never up. Measured on the shipped bytes, both files carry the page in a corner and pad the rest: content bounding box 394 × 848 and 394 × 846 inside a 780 × 1688 frame, with 74.9 % and 74.7 % of every sampled frame a flat #7E7E7E. This take declares 390 × 844, which is the same picture at the same fidelity with none of the padding.

EX-08 The critical flow walked once, with the write-off dialog on the path recorded by Playwright against the running Vite build · 390 × 844
Length, measured with ffprobe — the §17 window is 20–30 s
27.12 s
Frame, VP8 in WebM, no audio track
390 × 844
Frame rate, and frames counted in the file
25 fps · 678
Bytes on disk
1,434,986
Padding, as a share of every sampled frame
0 %
Caption cues, each read against its own frame
14 / 14
Cuts · speed changes · seeded states · stubs · navigations after the first
0
  1. CoversConnection, one rationale row opened, a provider chosen → Connect Brindle Savings → the three named stages → Home with its one-visit band → Home standing → the derivation row → the committed stream, scrolled → the inbound row → the invoice → the overflow menu → the write-off dialog → Keep the invoiceSet money aside instead → Reserve → Move £400.00 to reserve → the confirmation → Back to today at £840.00. It ends on Home, holding.
  2. Captions14 cues in media/rill-flow.vtt, written by the run that recorded the take, off the same clock as the beats — so the two cannot drift apart. The recording is silent; the cues describe the action. A cue may name only what is drawn or written inside the frame it is timed to — not a value that lives in an accessible name or below the fold. Each of the 14 was read against a frame extracted from the shipped file at an instant inside its own window.
  3. Driven byAccessible names only, through tests/states.ts — the same drivers the acceptance suite uses. The build has no QA route and no debug hook, so every frame is a surface a user can reach.
  4. Recorded bytests-video/critical-flow.spec.ts via playwright.video.config.ts, both under apps/rill/. Its testDir is ./tests-video against the acceptance suite's ./tests, so the take can never join or alter the acceptance count — which is 154 before and after.

There is no poster image: the element carries none, so the frame a reader sees before pressing play is the take's own first frame, which is the Connection screen. No QA control appears in any frame. A throwaway Playwright pass replayed the take's choreography and read every control's accessible name, the painted body text and the page HTML at each of the 14 dwell states the take holds on — 133 controls in all: 0 States / Help / Reduced motion / Debug / Inspector control, 0 role="switch", 0 leaked state IDs, 0 painted occurrences of seed, fixture or world, and 0 occurrences of the concept disclosure. The one data-testid the sweep found in any frame is underlay, an inert 390 × 844 transparent element react-aria-components renders under its own popover; it carries no text and it is not Rill's.

27.12 s One take · 678 frames
14 cues · 0 audio streams

Part two The examination — the defect class this build was designed against

⑤b Gate C step ⑤b
5 found + 2 in the a11y tree

Two constants that were wrong together

Four projects in this rebuild passed Gate C and then failed the same check: a quantity that moves with state, beside a surface written as a constant that had stopped following it. Three of the four were visible pixels. No detector reports it. Nothing overlaps, nothing is clipped, nothing is under-contrast; the label is well formed and simply false. Rill is the first build in the sequence that was designed against the class rather than swept for it.

The mechanism, in two files and one suite

src/data/fiction.ts holds facts only — a payment's date, name, cadence and amount; an invoice's lines; the times the source was contacted. It holds no running balance, no bucket total, no committed sum, no count and no "N days overdue". src/data/derive.ts computes every quantity once and builds every string that carries one, including accessible names and live-region text, because the standard counts those as surfaces.

apps/rill/src/data/fiction.ts:9–21 — quoted verbatim, comment included

  ── What this file may and may not hold ────────────────────────────────────
  It holds FACTS: a payment's date, name, cadence and amount; an invoice's lines;
  the times the source was contacted. It does NOT hold anything DERIVED from
  those facts — no running balance, no bucket total, no committed sum, no count
  of anything, no "N days overdue". Every one of those is computed once in
  `derive.ts` and read from there by every surface that paints it.

  That rule is not tidiness. VISUAL_ACCEPTANCE_STANDARD.md §3⑤b was added
  because all four preceding projects shipped a screen where one surface carried
  a quantity as a constant while another computed it, and the two stopped
  agreeing. Three of the four were visible pixels and no detector could see any
  of them. A stored `runningEdge2: 476.0` beside a stored `total: -2688.0` is
  exactly that defect waiting for its next edit — so neither is stored.

tests/quantity-agreement.spec.ts enforces both halves. Statically it casts the net over every string literal containing a digit in every file that paints copy — the corollary an earlier sweep missed by looking only for ternaries — with four exemptions, each naming the file, the reason and the evidence: Icons.tsx (SVG geometry; no SVG in the product contains text), StatusBar.tsx (the operating system's own 9:41 and 5G, inside an aria-hidden subtree), useViewport.ts (the two declared frame widths, used as numbers) and main.tsx (module specifiers only). At runtime it reads the surfaces off the rendered page and compares them, state by state.

Its first run caught a real one: Readings older than 90 days are dropped. and its disclosure line had the retention window written twice, in a screen file. It is now RETENTION_DAYS.

The five it found before any review saw the build

Two are the exact shape of the class. ⑤b-1 is the sharpest thing in this project: the filter chip said 7 earlier payments hidden, and 7 + 3 = 10 matched the row count exactly — so the arithmetic looked sound. Measured against the data authority's own table, the stream has ten rows and exactly two are dated 2 April or later, so the filter shows 2 and hides 8. Both numbers were wrong, and wrong together, which is why the pair passed inspection. They now come out of one partition, so neither can be wrong on its own.

⑤b-2 is the one that would have embarrassed the product most: a composition panel printing Committed to 3 April −£2,192.60 beside a hero of £0.00. £4,182.60 − £2,192.60 − £750.00 is £1,240.00, not £0.00the subtraction on screen was contradicting its own answer. The ninth payment makes the committed figure −£3,802.60, which is what the repaired panel prints.

The five ⑤b instances found inside the build, before review
#WhereWhat the screen saidWhat was true
⑤b-1timeline-negative-filteredchip 7 earlier payments hiddenthe filter shows 2 of 10 rows, so 8 are hidden
⑤b-2home-negative−£2,192.60 beside a hero of £0.00the ninth payment makes it −£3,802.60
⑤b-3sync-stale · sync-errora newest record row reading 09:12 · Reading takenin this world that reading never arrived; the record has 3 rows, not 4
⑤b-4sync-attemptingThese figures are current. while the request was in flightthe attempt is a row in the record, not a reading; the panel keeps the previous stamp until it succeeds
⑤b-5reserve-confirmedSafe to spend £1,240.00 two rows above Safe to spend £840.00one quantity, two values, and nothing on screen calling either a "before"

Two more were found in the accessibility tree alone, which is the half of this check most sweeps skip: Today carried the accessible name Today, current page on every screen, including while Timeline was open; and aria-selected="true" stayed on Today while the pushed data screen was in front of it, where the spec says no tab is selected. The delivered build said "no tab is selected" in CSS only.

What the artifact holds

reports/rill/QUANTITY_AGREEMENT.json carries 25 measured comparisons and 0 disagreements, every row tagged mobile-430. The suite runs at both viewports — that is why the acceptance count includes 18 quantity-agreement tests rather than 9 — but the file that ships holds 25 rows, and this page says 25 rather than 50 because 25 is what the file contains.

EX-09 The two ⑤b surfaces, repaired, at twice the product's own scale cropped in CSS from the audit captures · no new image
⑤b-1 · timeline-negative-filteredThe chip now names the crossing date and the hidden count, and both come out of the same partition — so the pair that was wrong together cannot be wrong separately either. Device-relative 16, 515, 358 × 64.
⑤b-2 · home-negativeThe hero, the derivation row and the panel now come from one computation: £4,182.60 − £3,802.60 = £380.00, which is £370.00 short of the £750.00 reserve, and the hero floors at £0.00. Device-relative 16, 175, 358 × 348.
25 / 0 Comparisons measured
disagreements found
F11 W4 · the first run
12 defects fixed

The audit had never looked inside a sheet

The implementation model's output was source only — it had never been compiled or run. Installing and running it produced twelve defects. The first killed the app: tab panels were rendered before the tab list, so React Aria had no tab to bind them to and the next selection change threw, taking the subtree down — 40 page errors in a single navigation. The second put a control off the screen: flex: 1 1 0 on the value field left min-width: auto, so the input was floored at its intrinsic twenty-character width and the 358 px row resolved to 760 px, putting + at x 704 in a 390 px frame.

The eleventh is the one that bears on how much any of this could be trusted. The overlays were portalled to document.body, outside the audit root — so all six detectors had been measuring the screen behind every sheet, dialog and menu, and had never measured one's interior. The scrim covered the browser window rather than the frame it is sized against.

How it surfaced, which is the part worth keeping

The harness reported it itself: invoice-reminder-sheet and invoice-writeoff-dialog came back with byte-identical DOM. Two states that share no content cannot render the same document, and the only way they could is if the instrument was not looking at the thing that differed.

apps/rill/src/components/Sheet.tsx:12–14, :37–41 — quoted verbatim

  They are portalled INTO the device element, so the scrim covers exactly the
  frame §0.6 sizes it against — and so the state audit, whose root is `.device`,
  measures what is inside a sheet rather than the screen behind it.

  const container = useDeviceContainer()
  return (
    <ModalOverlay
      UNSTABLE_portalContainer={container}

The same prop is passed by the invoice screen's popover at InvoiceRisk.tsx:65, so the menu is measured too. All four overlays come back clean on every gated detector.

Why the dead-space count went from 3 to 16, and why that is the right direction

Six of the sixteen dead-space findings exist because the sheets are now inside the audit root. While a sheet is open React Aria marks the screen behind it aria-hidden, so the detector correctly counts it as carrying nothing perceivable, and the scrim is a full-bleed tint skipped by contract. What is left is a scrim above a bottom sheet, which is what a bottom sheet is. A rise in findings caused by measuring more is not a regression, and the alternative — putting the overlays back outside the root — would return the number to zero by not looking.

All 16 dead-space findings, accepted one at a time — reported, never gated
ClassStatesFindingsWhy it is accepted
A — a modal state's frame is a scrim and a sheetconnect-progress-stage-1, invoice-reminder-sheet, invoice-writeoff-dialog, invoice-menu-open9the price of measuring inside overlays at all
B — the sync record has as many rows as things happenedsync-healthy, sync-manual-entered, sync-stale5adding a fifth row would be inventing content
C — the confirmed reserve state, at 430 onlyreserve-confirmed2two blocks admit exactly three voids; 421 ÷ 3 = 140.3 px against a 131.8 px cap

Class C was repaired as far as the arithmetic allows before it was accepted. The delivered build left the whole difference at the foot — one void of 219.5 px at 390 and 307.5 px at 430 — and applying the slack rule that already existed removed the 390 finding entirely. At 430 it cannot be removed without inventing a third block or loosening a threshold, and neither is taken.

One further pair was examined rather than assumed: connect-progress-stage-3 and home-first-visit share a domDigest. The captures are different files (154,767 and 209,117 bytes) and the two screens share no block. The digest is 32-bit; at 55 renders a collision is ordinary. Recorded as a collision, not repaired as a defect — and it means digest equality cannot be used as a regression check.

EX-10 The two overlays the audit could not see, now inside the root state audit captures · 1170 × 2532
Rill's connect progress sheet over the dimmed connection screen: Connecting to Brindle Savings, with Reading your balances active and two further stages pending.
connect-progress-stage-1The sheet occupies the lower 420 px of the frame — measured device-relative at 0, 424, 390 × 420. The emptiest screen in the build, and accepted: the sheet holds its height so the stage rows do not jump as stages complete.
Rill's write-off dialog centred over the dimmed invoice screen, with Write it off in red and Keep the invoice in blue beneath it.
invoice-writeoff-dialogA dialog of record: not dismissable by scrim or Escape, and initial focus is on Keep the invoice.
3 → 16 Dead-space findings,
because six detectors
started looking
55 / 55 state audit
unreachable 0

Coverage is driven, not claimed

Every public state in the inventory is reached through a control the product exposes, photographed, and measured by six DOM detectors on the running build. The matrix is the authority and the harness asserts that its own registry totals the matrix, so the two cannot drift.

37Public states
enumerated
37Reached
unreachable 0
55Renders
37 + 18
154Playwright tests
both viewports
52Storybook stories
52 with play()
28axe runs
0 violations

The six detectors, across all 55 renders

Mobile preset, unmodified — no threshold changed, no state excluded, no detector disabled
Detector390 × 844430 × 932Gate
overlap · intersection ≥ 1 px on the minor axis00gate
overflow · beyond frame > 0.5 px00gate
truncation · ellipsis or line-clamp actually firing00gate
touch-target · hit area < 44 × 4400gate
contrast · < 4.5:1, large text 3:100gate
contrastIndeterminate · text over a raster00measured, not waived
dead-space · void > 0.15 × device height97reported, never gated

Read the JSON carefully. STATE_AUDIT.json's findingTotals is { "dead-space": 9 } — the primary viewport only. The all-render total is 16, and it is the sum of the two columns above. A reader who quotes the JSON's own summary field quotes 9 and is wrong by seven.

Thresholds are the mobile preset unaltered: TOUCH_TARGET_PX 44, DEAD_SPACE_RATIO 0.15, OVERLAP_MIN_PX 1. Weight 700 is unused across the whole type scale on purpose, so no run qualifies for the WCAG large-bold allowance and every pair is judged at 4.5:1.

The flow, and the three places it is allowed to fail

Five forward transitions and two returns. Every trigger is a single decided control, and none of them is a swipe, a long-press or a tab activation. The tab bar reaches four destinations at any time; that is navigation, not the flow.

  1. T1

    1. Connection2. Home

    Activate Brindle Savings in the provider list, then Connect Brindle Savings. Three named stages run, then Home opens carrying a connected band for one visit only.

  2. T2

    2. Home6. Sync Recovery

    Activate the reading-age row in the Home header — the whole 358 × 44 row is the control. Sync Recovery is pushed; the tab bar stays and no tab is selected.

  3. R1

    6. Sync Recovery2. Home

    Activate Done. A return, not a new destination: Home's scroll position is preserved and the reading-age row carries the newest stamp.

  4. T3

    2. Home3. Cash Timeline

    Activate the derivation row 8 committed payments to 3 April. Timeline opens at the top with the Committed reading selected.

  5. T4

    3. Cash Timeline4. Invoice Risk

    Activate the 27 March row — the only inbound row in the stream, carrying an Expected chip and a +£1,850.00 figure. Invoice Risk is pushed with a back chevron.

  6. R2

    4. Invoice Risk3. Cash Timeline

    Activate the back chevron. The timeline re-opens at the offset it was left at, with the 27 March row still selected.

  7. T5

    4. Invoice Risk5. Tax Buffer

    Activate Set money aside instead, the text control beneath the two filled actions. It exists because this invoice is excluded from the forecast.

  8. T6

    5. Tax Buffer2. Home

    Set the amount and activate Move £400.00 to reserve. The selector is replaced in place by the confirmation; Back to today re-opens Home at £840.00.

  • Edge 1 · stale data · lives on 2. Home

    The newest reading is 18 March 07:04 against a clock reading 20 March 09:41. A 44 px band docks above the tab bar. Every figure keeps its value and its position.

    Exit
    Check for a new reading → 6. Sync Recovery, opening with the failed attempt already in its record.
    Decline
    There is none. The band has no dismiss control — the one non-dismissible surface in Rill.
  • Edge 2 · negative forecast · lives on 3. Cash Timeline

    A ninth committed payment takes the projection below the reserve on 2 April at £476.00, ending the horizon £370.00 under it. The figure is not recoloured and no error icon appears.

    Exit
    Show the payments after 2 April → the stream filters, and a chip states how many rows were hidden.
    Decline
    Not now closes the band for the session. The crossing marker stays, because the marker is data.
  • Edge 3 · source ended the session · lives on 6. Sync Recovery

    The 07:12 automatic refresh did not complete. The record's newest row states the attempt, the outcome and the reason; a sunk panel states the consequence in the user's terms. The last good reading stays on screen above it.

    Exit
    Try again → the retry succeeds at 09:44, the failed attempt stays in the record, and Home's stale band disappears.
    Decline
    Enter a balance by hand → a numeric field opens pre-filled, and the figure is marked entered by hand beside every place it appears.

Where the states live

6 + 6 + 7 + 6 + 5 + 7 = 37 public, plus 15 QA-only that exist as stories and appear in neither src/ nor dist/. Five public states are reached through the seeded world their own inventory row names — marked ·seed below — because the copy deck stamps each of them before the session opens, so no sequence of presses reproduces them. That is a gap in the fiction carried into the build, and it is recorded in each state's own note rather than smoothed over.

1. Connection · 6

  • connect-default
  • connect-progress-stage-1
  • connect-progress-stage-3
  • connect-provider-selected
  • connect-rationale-open
  • connect-search

2. Home · 6

  • home-after-reserve-move
  • home-default
  • home-first-visit
  • home-manual-figure
  • home-negative
  • home-stale

3. Cash Timeline · 7

  • timeline-all
  • timeline-committed
  • timeline-empty
  • timeline-negative
  • timeline-negative-dismissed
  • timeline-negative-filtered
  • timeline-row-selected

4. Invoice Risk · 6

  • invoice-menu-open
  • invoice-overdue
  • invoice-pushed
  • invoice-reminder-sent
  • invoice-reminder-sheet
  • invoice-writeoff-dialog

5. Tax Buffer · 5

  • reserve-confirmed
  • reserve-custom
  • reserve-default
  • reserve-large-value
  • reserve-preset-100

6. Sync Recovery · 7

  • sync-attempting
  • sync-error
  • sync-healthy
  • sync-manual-entered
  • sync-manual-entry
  • sync-recovered
  • sync-stale

The seven suites, and what each one is for

154 tests, every one run at both 390 × 844 and 430 × 932
SuiteTestsWhat it holds
critical-flow.spec.ts12T1 → T6, every trigger a single named control, plus the £25 grid ruling
edge-states.spec.ts20the three edges' triggers, exits and declines, and that none is ever the default
quantity-agreement.spec.ts18step ⑤b, static over every digit-bearing literal and runtime over the rendered surfaces
public-guardrails.spec.ts38public / QA separation, static on src/ and dist/, runtime on all six screens
keyboard.spec.ts22the eight React Aria machines, driven by key
target-size.spec.ts1644 × 44 on every control, plus the geometry the audit cannot see
a11y.spec.ts28axe-core on 14 surfaces — six screens, three edges, five overlay states

Accessibility, measured rather than asserted

14 surfaces × 2 viewports = 28 runs, tag set wcag2a wcag2aa wcag21a wcag21aa wcag22aa, scope body — the device frame plus the portalled overlays. No disableRules(), no excluded selector, no waiver list, and 0 violations at every impact, not merely 0 serious and 0 critical. Storybook's a11y addon runs at test: 'error', a stricter bar than the Playwright suite's, and that stricter bar is what found the heading-order defect.

The eight React Aria machines the spec binds — Tabs, ListBox, DisclosureGroup, RadioGroup, NumberField, SearchField, MenuTrigger, Modal — each have a keyboard test driven by key rather than by click, and a guardrail asserts that all eight are really imported and that the build imports neither Radix nor Ark UI.

The parameter that does not leak

Five states are reached by ?seed=, and the guardrail proves the parameter is invisible: exactly one URLSearchParams reader in src/, whose only key is seed; no painted string on any screen matching seed, fixture or world; no link carrying it; the default load is the healthy world; and an unrecognised value does nothing at all. A seed selects a world, never a screen and never a UI state, and it lands on the surface a returning user lands on.

0 / 0 / 0
0 / 0
overlap · overflow
truncation · touch-target
contrast, all 55 renders
PILOT 5 sources · stack
what is still wrong

Sources, stack, and what is still wrong

On the use of references

Reference study for this project was done through the Mobbin MCP server over three streamable-HTTP sessions: 17 tools/call requests inside 27 JSON-RPC calls, and 279 reference screens fetched from their own image URLs with 0 download failures. The study was structural — what a flow states before it asks, where freshness is declared, whether a negative forecast is a sentence or a recoloured figure.

No reference application is named on this page and no reference screenshot is published anywhere in this package. The reference receipt, the pattern synthesis and every fetched image live outside the repository's published tree, and a scan asserts that no git-tracked file carries a base64 image payload. What crossed into the build is a decision and its rationale, recorded against the flow it answers.

The stack, as installed

From apps/rill/package-lock.json — versions resolved, not requested
PackageVersionRole
react-aria-components1.19.0the required component system; 25 components across 8 import sites
react · react-dom18.3.1runtime
typescript5.9.3tsc -b runs before every build and exits 0
vite5.4.21bundler; dev port pinned to 5179 with strictPort
storybook10.5.452 stories, each mounting the shipped App
@playwright/test1.62.0154 acceptance tests, plus a separate config for the take
axe-core · @axe-core/playwright4.12.128 scans, no waivers
@fontsource/ibm-plex-sans · -mono5.3.0Latin cuts only — 5 faces, 10 files, self-hosted, no runtime network call

npm run build at this commit emits 414.00 kB of JavaScript and 25.72 kB of CSS with 0 type errors, plus two WebP rasters and ten font files. The harness report quotes 25.6 kB for the CSS; the figure above is what the build printed when this page was written, and the difference is a rounding in the report rather than a change to the build.

Two generated rasters, and nothing else generated

The product carries exactly two images: a paper grain used as a 3 % pseudo-element under the whole frame, and the closed ledger in EX-07. Two roles, two candidates each, one selected per role, zero regenerations. All four candidates were opened at full size before selection and none contains functional UI, a chart, a logo, a watermark, lettering, numerals, a human figure, a hand, a coin or a banknote. The generation ran on a free image path and charged $0.00 against a declared cap of $0.50; the empty cost ledger is recorded as a fact rather than left to read as an unbounded one.

Implementation

The build was implemented in a single Open Design run, b3fd8f8a-d43d-4294-9301-e68a6494133d, on claude-opus-4-8 with no fallback model observed. Every one of the 16 visual reference inputs and all 9 build-pack documents are recorded as consumed, with the mechanism named on each entry, and 0 inputs unread. Twenty-eight artifact files came out of it. That output is the thing described in F11: it had never been compiled.

What is still wrong, and was left

Reproduced from the acceptance review and the harness report rather than paraphrased into something softer.

Recorded weaknesses, all below the failure line
WeaknessStatus
The value field changes type size at £999.9948 px down to 20 px. It prevents an overflow the detectors would otherwise catch, but type resizing under a user's fingers while they type is coarse.Both states are in the inventory. The weakest moment in the build.
connect-progress-stage-1 is sparse — 456 px above the sheet and 204 px below the stage rows at 390.Accepted: the sheet holds its height so the rows do not jump as stages complete. Still the emptiest screen here.
Five of the 37 states are reached by seed, not by pressing anything, and no forward path exists in the copy deck.A gap in the fiction carried into the build, not a build shortcut. The honest fix is a Gate B change.
Decimal alignment down the running-balance column was never read at full size and has no recorded measurement.Outstanding. The one blind-spot row the build pack asked for that the harness did not close; it is asserted only by "the mono face is loaded".
UNSTABLE_portalContainer is a deprecated React Aria prop, and the F11 repair depends on it.It works and is pinned by the lockfile, but it will need revisiting on a major upgrade.
The stale band lost a painted control label: the repair makes the whole band one control, so Check for a new reading is no longer painted on Home.It survives in the band's accessible name and as the primary on the data screen. The arithmetic that forced it is recorded.
Three QA-only stories substitute a longer string in the DOM after the shipped component has rendered.The component, its CSS and its growth rule are the real ones; only the copy is the stress, and the fixture lives in no product file.
Motion is settled before every measurement, and is therefore never measured.True of every project in this sequence; the standard says so plainly.
Two 430-only voids on reserve-confirmed cannot be closed without inventing a third block.The arithmetic is recorded. The screen still reads emptier at 430 than at 390.
The inventory says the third bucket's total changes under the All reading. It does not, and the build does not make it.The reserve transfer stays inside the same account, so it moves no projection. What changes is the row count, 9 → 10, and the header, 9 payments → 10 items. Recorded rather than resolved by inventing a total.
0 Upstream repairs
spent on this build