Division one
The report — one file, six views, and a number that keeps saying what it is
Data Import
- Q1
- Q2
- Q29
The register was written before the first screen
The acceptance standard this portfolio is built against has a step ⑤b: for every state, list the quantities the screen asserts, and check that every surface naming the same quantity carries the same value — visible text, badges, tab labels, footers, progress positions, and aria-label and live-region text alike. It was added to the standard because a quantity that changes with state, printed on two surfaces, drifted in all four of the first four projects in this rebuild.
Acreline answered it at Gate B instead of at Gate C. Before any view was described, docs/build-pack/acreline/TASK_FLOW.md §0 listed every quantity the product would ever assert — twenty-nine of them — with the one selector that computes it, and SCREEN_SPEC.md §0.12 bound each selector to every surface that would render it, per view. The whole table is at § 15.
Four build rules follow from it, and the fourth is the one worth arguing about:
- No surface recomputes a quantity locally. A badge, a footer, a tab label, an aria-label and a live-region announcement all read the same selector.
- The noun is part of the number. Q10 counts gaps; Q11 counts the rows behind them. They are never printed as one figure and never in the same unlabelled position.
- A derived percentage is derived where it is printed, from the pair printed beside it. Where a denominator is zero the surface writes not comparable in words rather than dividing.
- Acreline prints no percentage set that must sum to 100. A four-part split of 1,169 t rounds to 99.9 at one decimal, and the cheapest repair — largest-remainder rounding — turns 6.9409 into 7.0. So the composition rows carry 704 t of 1,284 t, a pair, and no share in per cent appears on that view at all.
Rule 4 is the same move the project makes for geometry: remove the claim rather than manage the drift. It is enforced at runtime — a guard walks all six views and asserts that at most two percentages are painted on any of them, and that every composition row matches N t of N t exactly.
apps/acreline/tests/public-guardrails.spec.ts:395 — "no percentage set that must sum to 100 is printed anywhere"
for (const view of VIEWS) {
await view.go(page)
await settle(page)
const percentages = (await paintedText(page)).match(/-?−?\d+(\.\d+)?\s?%/g) ?? []
expect(percentages.length, `${view.name}: ${percentages.join(' ')}`).toBeLessThanOrEqual(2)
}
/* And the composition rows carry tonnes and a rule, never a share in %. */
await openScenario(page)
await settle(page)
const shares = await page.locator('.comp-share').allInnerTexts()
expect(shares.every((s) => /^\d[\d,]* t of \d[\d,]* t$/.test(s.trim()))).toBe(true)
View 1 is where the register starts. The file chip states 39,312 B · 11 columns · 248 rows; the requirements panel turns each of the four bounds the product will enforce into a measured value beside it — the format is CSV, the size is 39,312 B against 20 MB, the row count is 248 against 5,000, the header row is Detected; and the preview says Showing the first 8 of 248 rows. Six surfaces name Q1 in this one state, and the agreement check reads all six.
The requirements panel is the argument of this view. It does not say the file is valid; it prints each rule the product will apply and the value it measured, so a refusal later has already shown its working. On import-rejected the same panel marks the failing bound and states 5,412 beside 5,000 or fewer, and the forward control stays disabled.
Column Mapping
- Q2
- Q3
- Q4
- Q5
- Q6
A partition of eleven, and a second number beside it
The validation band is one sentence carrying four labelled numbers: 7 recognised · 3 need a decision · 1 excluded — 7 of 9 fields matched. The first three are a partition of the file’s eleven columns; they must sum to Q2 in every state, and the harness measures that sum on the painted verdict words rather than on the band, twice — on arrival and again when the mapping is complete.
The fourth number is not part of that partition, and the difference is the whole reason the band prints it separately. Q4 counts mapped columns; Q3’s clause counts distinct canonical fields. They agree until one field is claimed by two columns, and the moment they part is exactly the state a mapping screen has to be honest about — which is why the band is built to name the field in the same sentence rather than leave a reader to reconcile two counts.
apps/acreline/src/views/ColumnMapping.tsx:31 — the band, built from §0 selectors
const band =
`${q.q4} recognised · ${q.q5} need a decision · ${q.q6} excluded` +
` — ${q.canonicalMatched} of ${q.q3} fields matched` +
(q.conflicts.length > 0 ? ` — ${q.conflicts.join(" and ")} claimed by two columns` : "");
Three decisions later the same band reads 9 recognised · 0 need a decision · 2 excluded — 9 of 9 fields matched, and the footer restates it in its own words: 9 of 9 fields matched · 2 columns excluded. Nine plus zero plus two is still eleven. That invariant is what caught the one real ⑤b defect in this project, and it was in the specification rather than in the build — § 12.
A measured disagreement with the build pack, recorded rather than smoothed over
The state inventory says the destination combo box opens “on the four remaining canonical fields”. It offers three — Ship mode, Received date and Do not import — because seven of the nine fields are already held on arrival and the product does not offer a field another column holds. The data wins; the count is asserted against 9 − Q4 in the same render, so neither figure can be wrong on its own; and the disagreement is written into the state’s own record instead of being reconciled quietly.
Every verdict carries a word, never a colour alone — matched, needs a decision, excluded — and a runtime guard asserts it on every status marker in the build. The four sample values under each column are there so a decision about a column can be made from the column, not from its name.
Column Mapping
- Q1
- Q7
The zero case is written in words
Q7 is the one per-column percentage the product prints, and it is printed as a pair and a percentage together: a matched column reads 248 of 248 rows carry a value (100%) above All values pass validation. The percentage is computed in the expression that renders it, from the pair beside it, so the two cannot disagree.
The interesting case is the empty column. buyer_notes holds nothing, and rather than print a division it prints the same pair with a zero numerator and then replaces the verdict with a sentence:
0 of 248 rows carry a value (0%)
Nothing to validate
src/views/ColumnMapping.tsx:170 · :172 — the ternary is on the VERDICT, not on the figureAll eight sample cells beneath it read no value, so the summary and the sample it summarises sit on one surface and a reader can check one against the other without leaving the row. This is the small version of the rule the whole product is built on: a number is never left to stand alone where the thing it counts can be shown beside it.
The two column-level exits belong to an undecided column, not to a matched one. The inventory attaches them to a state whose column is already recognised; a matched column has no exits to offer, so the build records the disagreement on the state and evidences the exits here, where they are real.
Data Quality
- Q8
- Q9
- Q10
- Q11
- Q12
- Q13
Filtering changes what you see, not what is true
Data Quality opens on a single banner — 201 of 248 rows are ready · 47 need a decision — and a rail with one group per kind of outstanding decision. Selecting a group filters the table to Showing 34 of 47 rows that need a decision. and the group states 3 factor gaps · 34 rows.
The banner does not move. That is deliberate and it is asserted: filtering changed no row’s state, so the figure that counts rows is unchanged, and a product that redrew it would be telling the user that looking at something had altered it. The two numbers in the rail are the ones that move, and they are labelled with different nouns because they count different things — this is register rule 2, and it exists because a shipped product in the reference sweep sets “17 required values are missing” above a list of rows each reading “1 error”.
Resolving the first gap moves both figures in the same render: 219 of 248 rows are ready · 29 need a decision and 2 factor gaps · 16 rows. When everything is decided the banner changes what it counts, to 244 rows included · 4 excluded, and the rail says so in one line rather than emptying silently:
Nothing is waiting on a decision.
Every row is included or excluded. The audit pack records both.
src/views/DataQuality.tsx:110 · :203
The rail is also where the export blocker is visible from. The RATIONALE NOT RECORDED block names the same gap, the same origin and the same row count as the blocker on view 6, because both find the object rather than restating a string that happens to agree today.
the fix drawer
- Q10
- Q11
- Q21
- Q26
A sentence travels with the number
A factor gap is resolved by choosing something to stand in for the missing factor. The drawer offers three, each named and versioned and never valued — Glass, container — EU average · Vale Factor Set v3 and two others — and then asks for one sentence. The field’s placeholder is the part of this product I would keep if I could keep one line:
One sentence. It travels with the number into the audit pack.
src/views/DataQuality.tsx:289 — the rationale field's placeholderIt is not a label telling you what to type. It tells you where what you type goes, which is the only reason a person would take the field seriously. The forward control in the drawer reads Apply to 18 rows and is disabled until the sentence exists — so the number the decision will touch is stated before the decision is taken, on the control that takes it.
No numeric emission factor appears anywhere in the product, in any state. That is a rule of the fiction and it is also a guard: a runtime pass walks all six views and asserts that no decimal with two or more places is painted on any of them, and that the words that would introduce a factor value — per kg, factor =, coefficient — appear nowhere. A prototype that invented plausible emission factors would be a prototype that looked like it knew something, and it does not.
The resolution is then listed with its stamp, so what was decided and when is on screen rather than in a log: Borosilicate glass · Portugal over recorded 14 Apr 2026, 09:58.
The table stays visible while the drawer is open. The drawer takes 400 px and the table keeps the rest with its own filter line, because the decision is about those rows and hiding them to ask about them would be the wrong trade. At 768 the drawer becomes a 400 px docked bottom sheet rather than a side panel — a change forced by a defect the first run found, § 13.
Scenario Builder
- Q12
- Q13
- Q14
- Q15
- Q20
An estimate, not a measurement
The total is the largest thing on the view and it never appears without the sentence underneath it:
1,284 t CO₂e
1,130 – 1,438 t at ±12% factor uncertainty
Built from named factors and stated assumptions. An estimate, not a measurement.
src/views/ScenarioBuilder.tsx:46 — and, verbatim, in the block's accessible name at :37The claim and its qualification are one object. The same sentence is built into the total block’s accessible name, so the qualification is not a visual footnote that a screen reader skips past — it is announced with the figure it qualifies.
Above the total, a panel says what the number is built from before the number is read: 244 rows included, 4 rows excluded, Vale Factor Set v3. Below it, four composition rows, each with an illustration, a tonnage, and — instead of a percentage — 704 t of 1,284 t beside a proportional rule.
That rule is measured, not eyeballed. The build pack lists it as one of the things a reviewer has to open at full size, so the harness makes it mechanical instead: each rule’s width in pixels is asserted equal to t ÷ 1,284 × 356, giving 195.2 / 91.8 / 46.6 / 22.5, and the four are asserted strictly ordered. The four tonnages sum to the total exactly — 704 + 331 + 168 + 81 = 1,284 — and the agreement check reads that sum off the painted rows.
On the right, the flow plane: four origin plates, two freight plates and one destination, every label a row count or a tonnage read from a selector. 62 + 48 + 29 + 105 = 244, the same Q12 the scope panel prints; 178 t + 153 t = 331 t, the transport category exactly. Nothing on the plane is a written constant.
The four illustrations are the project’s only generated raster besides the ground. Five assets, two candidates each, one selected each, zero regenerations — ten images, and $0.00 spent against a $0.50 cap, because they were made on a free image path that never touches the cost ledger. The ledger records that as the reason rather than leaving an empty file to be mistaken for an unbounded one.
the range
- Q15
- Q17
The range says what it is not
The range line is a control. Pressing it opens an explanation in place — not a tooltip, not a help page — and the explanation is two paragraphs, the second of which is the reason this product exists:
Each factor in the set carries its own spread. The range is the total recomputed at the low and high end of every factor at once, which is the widest the arithmetic allows.
It is not a confidence interval and it is not a measurement error. It says nothing about how likely either end is.
src/views/ScenarioBuilder.tsx:79 · :82Almost every product that prints a range implies a distribution by printing it. This one states the construction, then removes the three readings a person is most likely to bring to it. It also declares its own width — ±12% — from a single constant, printed beside the range on all three surfaces that carry one, so the band and the number it widens cannot part company.
The panel replaces the composition rows rather than covering them. Nothing on this view floats except the two dialogs; an explanation that obscured the thing it explains would be making the same mistake as a tooltip.
Scenario Comparison
- Q16
- Q17
- Q18
- Q19
- Q21
- Q22
Two ranges, read against each other
One change — Ship mode changed from Air to Sea on 41 rows. — moves exactly one category. Transport falls from 331 t to 216 t; the other three keep their figures; the total becomes 1,169 t. The comparison then prints the difference twice, and the two printings are not the same kind of thing:
- −115 t is stored arithmetic: Q16 minus Q14, and the harness recomputes it from the two cells printed beside it.
- −9.0 % is derived at print time from the same two operands, which is why it moves when they move and the absolute does not (§ 9).
Under them is the sentence I would point at if asked what “interpreting your own numbers without overclaiming” looks like in practice:
The two ranges overlap between 1,130 t and 1,309 t, so the 115 t difference is larger than the gap between them but smaller than the width of either range.
src/views/ScenarioComparison.tsx:126 — the bounds are max(low) and min(high), computed hereIt draws the comparison a reader would otherwise draw badly, states the limit of it in the same breath, and stops. It does not say which scenario is better.
Two rows below are worth reading as a pair. Rows included shows 244 against 244 and a difference of 0; Factor set shows the same version in both lanes and a difference of same. Both cells are derived in the expression that renders them — two integers give a signed subtraction, two strings give a word — and 0 is printed rather than left blank, because an absent row and an unchanged row are different facts.
At 768 the lanes stack, and that is measured rather than asserted
768 × 1024 is a different composition, not the same one squeezed: the left rail becomes a 56 px icon rail, the drawers become docked bottom sheets, the target floor goes back to 44 px, and the two comparison lanes stack into one column. The stack is measured — one grid column, every cell at the same x — and asserted in a story of its own.
At 1280 this view was 1,416 px wide inside a 1,281 px frame when it was first run, and the whole difference column was off screen. Nothing reported it: the region scrolls, so the overflow detector correctly found nothing. It was found by measuring the frame’s own rectangles against the geometry the spec states — § 13, defect F22.
edge state 2
- Q19
- Q23
- Q24
- Q25
A version change prints its consequences first
The analysis is pinned to Vale Factor Set v3. A newer set exists, and the comparison says so in a band beneath its own header: Vale Factor Set v4 was published on 9 April. 2 of the 9 factors used here changed. Every figure keeps its value and its position while the band is up — nothing is greyed, nothing is marked stale, nothing is hidden behind the notice. The band is information, not a modal in disguise.
That count is two lengths rather than two constants: the product holds the list of factors the analysis uses and the list changed in v4, and prints 2 of 9 from them. It never names which two, because it does not know what a reader would do with that.
Choosing to act on it opens the one dialog in the product that is genuinely a decision, and the dialog prints the consequences before the choice:
Baseline 1,284 t → 1,301 t
Proposal 1,169 t → 1,186 t
Difference 115 t → 115 t, −9.0% → −8.8%
Recomputing creates a new revision. The v3 revision is kept.
src/views/ScenarioComparison.tsx:256–264 — every figure computed, none writtenThe third line is the register earning its keep. The absolute difference does not move and the relative one does, because Q19 is derived at print time from a denominator that moved: 115 ÷ 1,284 = 8.96 % and 115 ÷ 1,301 = 8.84 %, printed as −9.0 % and −8.8 %. A product that stored the percentage would have shown −9.0 % in both columns and been quietly wrong in the one place a person is most likely to quote.
And the last line is the difference between a warning and a decision: nothing is destroyed. The v3 revision stays in a list and stays selectable. The dialog opens with Keep v3 holding focus, not the action; an outside click does not decide anything, because the dismissable behaviour that made a stray click on the scrim silently record a decision was removed — § 13, defect F15.
The dependents are counted by kind, not summed. 1 scenario and 2 saved views — three things, but never 3, because a scenario and a saved view are not units of the same thing and a person deciding needs to know which is which.
Provenance Export
- Q12
- Q13
- Q26
- Q27
- Q28
- Q29
A refusal that carries its own measurement
An analyst who opens Provenance Export before repairing the analysis gets a refusal, and the refusal is built like a finding rather than like an error. It states the measurement and the threshold together, enumerates what is blocking, says which view owns each blocker, and offers a route to it:
Why can’t I export yet?
Assumptions with a recorded rationale: 4 of 5
On Data Quality — one assumption has no recorded rationale: Nitrile · Malaysia, 5 rows. Go to Data Quality
On Scenario Comparison — this analysis is pinned to v3; v4 was published on 9 April. Go to Scenario Comparison
src/views/ProvenanceExport.tsx:126 · src/derive/quantities.ts:218–247 — the blocker finds its object4 of 5 is doing the work of a sentence: it is the measurement and the bar in the same breath, so “why not” and “how far off” are one reading. And the blocker text is built by finding the gap — the material, the country and the row count are read from the same data the rail on view 3 reads — so the two surfaces cannot drift into naming different things.
The refusal is announced as well as drawn: the heading and its measurement sit inside one live region, so a screen reader hears the refusal with the number that turns it off. That shape is the repair for a real defect — role="status" had been put on the heading alone, which is not allowed on an h2 and would have announced the refusal without its measurement.
The pack names what it does not contain
With both blockers cleared, the panel lists the audit pack as two columns: Included (6) and Not included (3). The second column is the unusual one. Most products list only what they include:
Not included (3) — the file as uploaded · the 4 excluded rows · the factor library itself
src/views/ProvenanceExport.tsx:149–156 — and the row count is Q13, read rather than repeatedThe middle item is a slot rather than a string: it reads the excluded-row count from the register, so the pack’s own description of its hole cannot go stale. And the export control states the scope it will write — Export audit pack · 244 rows — before it is pressed.
The lineage plane is a group, not a picture. It was first built as role="img" with sixteen buttons inside it — a role whose subtree is presentational, so a screen reader honouring it would never reach a single node. It is now a labelled group whose plates are operable, and the flow diagram on view 4, which has no operable child, stays an image.
the take
- 27.80 s
The same analysis again, walked once
One take, no cuts, at the primary viewport. It goes from an empty import to the export panel through the product’s own controls, resolved by role and accessible name using the same drivers the acceptance suite uses. There is no seed, no QA route and no URL parameter in this build, so every frame is a surface an analyst can reach; nothing is stubbed for the camera and no state is written directly.
27.80 s1440 × 900VP8 / WebM25 fps 695 frames2,496,208 B0 audio streams13 caption cues
Where it stops, and what it cannot show
The last beat is the export panel with the pack listed and the control live. It is not pressed: the frame after an export is a stamp, a digest and a Download again control, which is a receipt rather than a decision, and the decisions are what this product is for.
Edge state 3 — the refusal in Plate XI — is not in the take, and the first cut of the captions claimed it was. Reaching Provenance through the forward flow clears both blockers on the way: resolving the Nitrile gap in the drawer records the rationale, and Keep v3 settles the version. Edge 3 is reached by opening Provenance from the left nav before repairing the analysis — a different session, not a later beat of this one. The caption was corrected after the frame inside its own window was extracted from the take before this one and read: it showed the export control live.
Three more captions did not survive their own frames
One said 331 t becomes 216 t over a frame that prints 216 t and not 331 t. One said the two verdicts that changed over a frame showing three. And the first cut’s sweep caption held a 0.957 s window over five separate decisions, which is not a description of anything; it is now two captions, each timed to a frame the take actually holds. Every cue was checked against a frame extracted from the finished file with ffmpeg -accurate_seek -ss <t> -frames:v 1, and the table of thirteen is in PACKAGE.md.
And a note about the frame itself
video.size equals the viewport. Playwright only ever scales a page down to fit the size a config requests, never up, so a take that declares a size larger than its viewport carries the page in one corner and pads the rest with flat grey. Two mobile takes in this portfolio shipped that way and were re-recorded. Measured on this file at 6 s: content bounding box 1440 × 900 in a 1440 × 900 frame, 0.0 % of it padding.
Division two
The examination — what was measured, what was found, and what is still wrong
44 × 3 = 132
- Q2
- Q4
- Q5
- Q6
Step ⑤b, and the two defects it found in the documents
reports/acreline/QUANTITY_AGREEMENT.json holds 44 distinct comparisons, measured at three viewports, for 132 rows, and the file contains 132 rows. That precision is deliberate: the previous project’s report implied fifty measurements over a twenty-five-row artifact, and this build changed to a shard-merge so that the file holds what the report says it holds. Across those 132 rows the file records 0 disagreements, over 513 individual surface readings.
Accessible names and live-region text are read as surfaces, because the spec counts them: the table’s aria-label and aria-rowcount, the total block’s accessible name, each composition row’s accessible name, the difference cell’s accessible name, the drawer’s accessible name and the step indicator’s Step N of 3 are all compared against the painted figure.
Both defects were in the build pack. Neither was on a screen.
That is the point of having answered ⑤b at Gate B. With one selector assigned to each quantity before any view existed, the implementation had nowhere to drift to; what was left to catch was the register’s own arithmetic.
⑤b-1 · TASK_FLOW §0, Q4 — a definition that contradicted its own invariant
Q4 was defined as columns.filter(c => c.mapped && c.auto).length with a constant value of 7. The && c.auto freezes Q4 at 7 while Q5 falls, so Q4 + Q5 + Q6 = Q2 — the invariant the same document states — breaks the moment a column is matched by hand: 7 + 1 + 2 = 10 ≠ 11. The band would have asserted a partition of eleven columns that accounted for ten.
The arithmetic table won. It is the one place the register’s own invariant is written out, and the same document’s own tables move Q4 7 → 8 → 9 and state 9 of 9 fields matched at the end of the flow. The build computes cols.filter(c => c.mapped).length; §0 and SCREEN_SPEC.md §2 — which had copied the same clause, ⑤b-2 — were corrected, with the correction recorded rather than the row quietly rewritten.
Two near-misses were examined and kept rather than "fixed":
- 9 recognised against 9 of 9 fields matched — different quantities, agreeing at the end of the flow and parting the moment one field is claimed twice, which is exactly what the conflict state shows.
- 0 in one difference cell against same in another — both derived where they are rendered, from different types, and both asserted.
The visible arithmetic, checked rather than taken on trust
- 704 + 331 + 168 + 81 = 1,284 tthe baseline total exactly, read off the painted rows
- 704 + 216 + 168 + 81 = 1,169 tonly Transport moves
- 1,169 − 1,284 = −115 t; 115 ÷ 1,284 = 8.96 % → −9.0 %Q18 stored, Q19 derived at print
- on v4: 1,301 and 1,186, still −115 t; 115 ÷ 1,301 = 8.84 % → −8.8 %the percentage moves when its own operands move
- 201 ready + 47 pending = 248; gap 1 is 18 rows → 219 and 29both printed, both measured
- 18 + 11 + 5 + 9 + 41 = 84, but the union is 66the sum is printed nowhere; per-assumption counts are not additive, and the view says so in words
24 defects
- F1–F24
The first time it ran, and the twenty-four defects that were there
The design step produced source that compiled. That was the entire extent of what was known about it: it had never been driven. The first time it was, two of the thirty-four public states could not be reached at 1440 and nine could not be reached at 768. Those, and twenty-two more, were repaired before anything was reviewed.
Four of them made public states unreachable, and none of the four is the kind of thing a screenshot shows:
| # | What was wrong | Measured |
|---|---|---|
| F1 | The destination combo box’s trigger was a bare chevron with only a colour declared. | 6.45 × 20 px — under the 24 the preset requires at every width, and under the 44 this project puts back at 768 |
| F2 | Because of F1, one public state could not be reached at all: a press on that trigger did not open the popover. | the state timed out after 120 s in the audit |
| F3 | Both drawers shipped with no height, so each took its content height at the foot of a column that was already full — and every state downstream of that press is downstream of a clean quality review. | Apply to 18 rows at y 1180 in a 1024 px frame: outside the viewport and unreachable by scrolling. 9 of 34 states unreachable at 768 |
| F4 | React Aria puts the checkbox role on a one-pixel-clipped input that the label covers, so every press retried until timeout. | 13 × 13 at 280,509. The drivers now press label.a-checkbox, which measures 414 × 58 |
The rest fall into four groups, and the useful thing about them is how they were found.
- Six ranks of control were still at desktop height at 768, and controls were being squashed below their declared height by flex-shrink — 4 assumptions measured 114 × 20 against a declared 24, a matrix trigger 168 × 20 in a 56 px row, five breadcrumb items 23 – 111 × 20. None of that is visible in a screenshot. It was found by measuring every control against the preset’s own bar, at all three viewports.
- Four defects were geometry against the spec’s own arithmetic. The comparison lanes were 1,416 px wide in a 1,281 px frame; a dialog control sat 10 px off the frame at 768 and a breadcrumb chip 48 px off it; the lineage plane declared 1,184 px and, under box-sizing: border-box with its own 1 px rules, left 1,182 for 1,184 of content, clipping the last column and producing 11 overrun findings that were all one pixel pair. It is 1,186 px now and the findings are 0.
- Eight were accessibility defects that the stricter of the two bars caught. Storybook fails a story on any axe violation, which is stricter than the Playwright suite’s serious-and-critical bar, and it is what found a role that is not allowed on an aside, a status role on a heading, an empty table header and a heading order that jumped h1 to h3. Four scrollable regions no keyboard could reach, an excluded row at 2.9:1 and the lineage plane’s role="img" came from the same sweep.
- Two were behaviour that looked fine and was not. Both dialogs opened with the container focused rather than the cancel control. And an outside click on the version dialog recorded a decision: the dismissable behaviour fired one of the edge state’s two exits silently, and injected a one-pixel dismiss button ahead of Cancel in the focus order. Both dialogs are now among the two moments the product does not allow to be interrupted.
One more, and it is the one I would keep as the argument for having a fixture at all: repairing F7 introduced F24 — a flex item’s automatic minimum size is its content, so a 64-character header overran its column instead of wrapping — and the fixture written to prove that headers wrap caught it in the same run.
64 renders
- 34 / 34
Coverage is driven, not claimed
Every one of the 34 public states is reached by a driver that goes through a control the product exposes, resolved by role and accessible name. This build reads no URL parameter at all — there is no seed, no fixture switch and no QA route — which is proven statically in src/, statically in the built dist/ with a staleness check, and at runtime on all six views at all three viewports. A state the harness can reach is therefore a state a user can reach, without qualification.
The six detectors, over all 64 renders
| Detector | 1440 × 900 34 renders |
1280 × 800 13 renders |
768 × 1024 17 renders |
All 64 | Gate |
|---|---|---|---|---|---|
| overlap | 0 | 0 | 0 | 0 | gate |
| overflow | 0 | 0 | 0 | 0 | gate |
| truncation | 0 | 0 | 0 | 0 | gate |
| contrast | 0 | 0 | 0 | 0 | gate |
| touch-target | 1 | 0 | 0 | 1 | gate — accepted, below |
| contrastIndeterminate | 0 | 0 | 0 | 0 | measured, not waived |
| dead-space | 57 | 20 | 18 | 95 | not a gate — human judgement |
The JSON’s own summary field counts the primary viewport only. findingTotals reads {"dead-space": 57, "touch-target": 1} — 58 — and a reader quoting it as the project total would be short by 38. The table above is the walk over all three viewports. Thresholds were the SaaS preset’s own, unmodified: target 24 (44 at 768 by the preset’s per-viewport override), dead-space ratio 0.20, overlap 1 px, contrast 4.5 and 3. No threshold was changed, no state excluded, no selector added to any exclusion list and no detector disabled. domDigestAliases is empty — the only project of the six with no DOM-digest collisions and no aliases to declare.
The one touch-target finding, and why it is still in the report
It is F4’s element: React Aria’s own checkbox input, clipped to one pixel inside the label, reported at 13 × 13. The easy handling would have been to add it to the profile’s decorative-selector list, which would remove the finding from the audit. That list is untouched — the three entries it already was.
Instead the measurement was added. tests/target-size.spec.ts measures label.a-checkbox — the thing a person presses, 414 × 58 at 1440 and ≥ 44 at 768 — against the same preset bar at all three viewports, and skips the nested input structurally, by the clip that hides it, rather than by name:
apps/acreline/tests/target-size.spec.ts:88
if (node.closest('label.a-checkbox, label.proxy-option') && node.tagName === 'INPUT') continue
The finding stays visible as a true statement about React Aria’s DOM, beside a true measurement of what a person presses.
Dead space: 95 findings, and not 95 defects
Dead space is reported for human judgement and never gated, because the failure it invites — inventing content to fill a rectangle — is worse than the void. Here the judgement is easy once the findings are grouped, and grouping them is the whole point:
- 64 of the 95 are one fact about the left nava six-item rail in a full-height column; reported once per render, in every state, at every width, by construction
- 9 are the issue railas many groups as there are kinds of outstanding decision — three, then fewer, then none
- 6 the blocked export panel, 5 the preview pane, 4 a comparison regiona fixed 200 px panel holding two blockers; nothing to preview before a file is chosen
- 3 the fix drawer, 2 the table pane, 1 each for the done panel and the import panela drawer holds one gap's three options and one field
Adding a seventh place to the navigation to fill 530 px is inventing content; so is padding a table to the fold. The metric is noisy on this shape, and a reader who quotes the raw 95 will badly misread the build — which is why it is decomposed here rather than defended.
A correction to the acceptance review, made by re-measuring rather than by trusting it
The Gate C review reports this split as 62 nav findings and 31 on main.content. Walking every finding record in STATE_AUDIT.json gives 64 on nav.leftnav and 31 spread across eight other selectors, none of which is main.content — that selector produces no dead-space finding anywhere in the audit. The review’s judgement is the one this page follows and it is unaffected; its two numbers are not what the file holds, and the harness report’s own class table already says 64. Recorded here rather than reproduced.
Where the states are, and which of them are public
-
1. Data Import
4
- import-empty
- import-parsed
- import-header-off
- import-rejected
-
2. Column Mapping
6
- map-auto
- map-column-open
- map-unmapped-selected
- map-excluded
- map-coverage-zero
- map-complete
-
3. Data Quality
7
- quality-all
- quality-gap-selected
- quality-fix-drawer
- quality-gap-resolved
- quality-unit
- quality-exclude-dialog
- quality-clean
-
4. Scenario Builder
5
- scenario-baseline
- scenario-editing
- scenario-applied
- scenario-assumptions
- scenario-uncertainty
-
5. Scenario Comparison
6
- compare-default
- compare-category
- compare-assumptions
- compare-version-conflict
- compare-version-dialog
- compare-recomputed
-
6. Provenance Export
6
- prov-default
- prov-node-selected
- prov-row-evidence
- prov-export-blocked
- prov-export-ready
- prov-export-done
4 + 6 + 7 + 5 + 6 + 6 = 34, which the harness asserts against the inventory’s own matrix so the two cannot drift. Eighteen more states are QA-only: they exist in Storybook, are asserted absent from src/ and from the built dist/, and 34 + 18 = 52.
The forward flow, and the three places it is allowed to fail
- T1Data Import → Column MappingContinue to mapping — disabled until a file is parsed, and on the empty state it carries the reason Add a file to continue
- R1Column Mapping → Data ImportBack to the file — the parse is kept, the mapping decisions are kept, nothing is re-parsed
- T2Column Mapping → Data QualityContinue to review — disabled while any column needs a decision, and the footer states the count that blocks it
- R2Data Quality → Column MappingBack to mapping — every resolution already recorded is kept, because a resolution is a decision about data rather than about the mapping
- T3Data Quality → Scenario BuilderBuild a scenario — disabled while any row needs a decision
- T4Scenario Builder → Scenario ComparisonCompare with baseline — the single filled control in the footer, after the change is applied
- T5Scenario Comparison → Provenance ExportWhere does this come from — the difference row’s own trailing control
- R3Provenance Export → Scenario Comparisonthe Scenario stage of the audit breadcrumb. Every crumb is a control that does something, and a test asserts it — four of them did nothing at first compile
- Edge 1a factor gap on view 3exit: Resolve, through the drawer. Decline: Exclude these rows, behind a dialog that names the count. The banner itself has no dismiss control — the one non-dismissible surface in the product
- Edge 2a newer factor set published under the analysis, on view 5exit: Recompute on v4. Decline: Keep v3, which closes the band for the session and raises a pin chip. The band is up on arrival, and no seeding is needed to put it there
- Edge 3the export refusal on view 6there is no decline and no override. The blockers can be resolved and the export can be abandoned; it cannot be forced
The seven suites, and the two workshops
| Suite | × 3 | What it holds |
|---|---|---|
| critical-flow | 30 | T1 → T5 and back, the refusal-and-reason contract at each step, and the whole sequence with the URL never moving |
| edge-states | 36 | the three edges’ triggers, primary exits and declines; that edge 3 has neither; that no edge is ever the default |
| quantity-agreement | 33 | step ⑤b, static over every string literal containing a digit, and at runtime over the rendered surfaces |
| public-guardrails | 66 | public/QA separation in src/, in dist/ and at runtime; no URL parameter; no percentage set summing to 100; no numeric factor; no colour-only status; no filled destructive control |
| keyboard | 42 | the React Aria machines the spec binds, driven by key rather than by click |
| target-size | 30 | the preset’s bar on every control, plus the geometry no detector measures |
| a11y | 51 | axe-core on 17 surfaces × 3 viewports |
axe: 51 scans, 0 violations at every impact — not merely zero serious and critical. Tags wcag2a wcag2aa wcag21a wcag21aa wcag22aa, scope body so React Aria’s portalled modals and popovers are inside it, and no waiver, no disabled rule and no excluded selector. Every one of the 51 run records in AXE_REPORT.json carries an empty violation list; the file holds the runs rather than a summary of them, so that can be checked rather than believed.
Storybook: 52 stories, 52 with a play(), 52 passing — Compare 9, Import 7, Mapping 9, Provenance 9, Quality 10, Scenario 8. Each mounts the shipped application and reaches its state through the product’s own controls; the stories live outside src/, so no public entry point can import one. Their axe bar is set to fail on any violation, which is how four of the accessibility defects in § 13 were found.
Four inventory rows have no product surface, and the stories say so
A parsing state that does not exist because the transition is atomic; a zero-row change the product cannot construct because it offers exactly one change and that change matches 41 rows; and two “dense” states for planes that have no expansion control. Each story asserts the rule the row exists to protect — that the transition is atomic and ships no progress element; that no percentage of any kind is printed on view 4; that every pair of plates at the narrowest viewport clears 8 px; that the lineage plane’s sixteen plates have 12 distinct second lines and 16 distinct full labels, the four repeats told apart by line one. So the build implements 48 of the 52 rows as described and 4 as the rule beneath them, and a reviewer who counts states will find that rather than a smooth 52.
the register
- Q1
- …
- Q29
Appendix — the register, all twenty-nine
This is the table that was written before the first screen was described. The values are the canonical fiction’s; where a value moves through the flow the arrows are the states it moves through. The last column is the reason ⑤b had nothing to find on screen: every surface that names the quantity is a view of the one selector.
| # | Quantity | In the fiction | The one source | Views |
|---|---|---|---|---|
| Q1 | rows in the dataset | 248 | dataset.rows.length | 1, 2, 3, 6 |
| Q2 | columns in the file | 11 | dataset.columns.length | 1, 2 |
| Q3 | canonical fields | 9 | CANONICAL_FIELDS.length | 2 |
| Q4 | columns recognised | 7 → 8 → 9 | columns.filter(c => c.mapped).length | 2 |
| Q5 | columns awaiting a decision | 3 → 1 → 0 | columns.filter(c => !c.mapped && !c.excluded).length | 2 |
| Q6 | columns excluded | 1 → 2 | columns.filter(c => c.excluded).length | 2 |
| Q7 | row coverage of one column | per column | col.valuesPresent / Q1 | 2 |
| Q8 | rows ready | 201 → 219 → 244 | rows.filter(r => r.issues.length === 0).length | 3 |
| Q9 | rows needing a decision | 47 → 29 → 0 | rows.filter(r => r.issues.length > 0).length | 3 |
| Q10 | factor gaps | 3 → 2 → 0 | gaps.filter(g => !g.resolved).length | 3, 6 |
| Q11 | rows behind the gaps | 34 → 16 → 0 | sum(unresolved gaps → g.rows.length) | 3 |
| Q12 | rows included | 244 | rows.filter(r => r.included).length | 4, 5, 6 |
| Q13 | rows excluded | 4 | rows.filter(r => r.excluded).length | 3, 4, 5, 6 |
| Q14 | baseline total | 1,284 t | total(baseline) | 4, 5, 6 |
| Q15 | baseline range | 1,130 – 1,438 t | band(Q14, UNCERTAINTY) | 4, 5 |
| Q16 | proposal total | 1,169 t | total(proposal) | 5, 6 |
| Q17 | proposal range | 1,029 – 1,309 t | band(Q16, UNCERTAINTY) | 5 |
| Q18 | difference, absolute | −115 t | Q16 − Q14 | 5, 6 |
| Q19 | difference, relative | −9.0 % | (Q16 − Q14) / Q14 at print time | 5 |
| Q20 | category totals | 704 / 331 / 168 / 81 t | rollup(rowsIncluded, byCategory) | 4, 5, 6 |
| Q21 | assumptions on a scenario | baseline 4, proposal 5 | scenario.assumptions.length | 4, 5, 6 |
| Q22 | rows touched by at least one assumption | baseline 43, proposal 66 | distinct(union(a => a.rows)) | 5, 6 |
| Q23 | pinned factor-set version | v3 | analysis.factorSet.version | 1, 3, 4, 5, 6 |
| Q24 | current factor-set version | v4 | factorLibrary.latest.version | 5, 6 |
| Q25 | dependents of the pinned version | 1 scenario, 2 saved views | pins.groupBy(kind) | 5 |
| Q26 | assumptions carrying a rationale | 4 of 5 | assumptions.filter(a => a.rationale).length | 6 |
| Q27 | export blockers | 2 → 0 | exportBlockers().length | 6 |
| Q28 | items in the export pack | 6 included, 3 excluded | PACK_INCLUDED.length, PACK_EXCLUDED.length | 6 |
| Q29 | dataset import stamp | 14 Apr 2026 09:58 | dataset.importedAt | 1, 3, 6 |
The register is not a document that describes the build; it is a document the build is measured against. src/derive/quantities.ts computes all 29 in one place, and the agreement suite asserts both that it does and that no view file re-derives a register quantity locally with its own filter.
and residuals
- Pilot 6
Sources, stack, and what is still wrong
On the use of references
Before anything was designed, a preflight against a reference library made 19 real tool calls inside 28 JSON-RPC calls and fetched 292 screens with 0 download failures, which were tiled into boards and studied for structure. No reference application is named on this page and no reference screenshot is published — the study was structural, naming an application would imply a resemblance the build does not claim, and publishing the screenshots is forbidden by the package contract. The image-bearing material is excluded from this repository, and a byte-level scan for base64-shaped runs across every committed report file is part of the receipt.
The defects that sweep found in shipped products are cited in the build pack as the reason for three of the register’s four rules — a set count printed over a paged list, a total printed above rows each restating a different noun, and a difference of a real amount printed above an increase of 0.00 % repeated on every row. Those are the hazards this product is arranged to avoid; they are not accusations, and none of them is named here.
The stack, as installed
- react-aria-components1.5.0 — resolved, not the range in package.json
- react · react-dom18.3.1
- typescript · vite5.5.4 · 5.4.11
- storybook · @storybook/test-runner · addon-a11y10.5.4 · 0.24.4 · 10.5.4
- @playwright/test1.62.0
- axe-core · @axe-core/playwright4.12.1
- @fontsource/inter · newsreader · jetbrains-mono5.3.0 — the product's three families, self-hosted
- the buildtsc -b && vite build, 0 errors · 521.21 kB js (152.85 kB gzip), 57.02 kB css, 5 webp, 66 font files
Implementation
The application was implemented in a single Open Design run pinned to one model, whose receipt records the model observed on every one of 2,794 events, all 20 visual reference inputs and all 10 build-pack inputs consumed, 0 unread inputs, and 46 artifact files of which 28 were written or edited by the model. Everything in § 13 happened afterwards, in this repository, to that output.
What is still wrong, and was left
-
Two composition rules the spec states for 768 are not implemented.
The comparison’s label rail is not promoted to a row header above each lane’s values; the lanes stack, which is measured, but the promotion is a composition the build does not make. And the issue rail is not the 56 px horizontal strip the spec describes — it stays a bounded vertical block with its own scroll, because a 56 px strip cannot hold the groups, the resolved list with its stamps, and the control that clears an export blocker, and putting a public control out of reach is the defect F3’s repair exists to remove. The reason is good; the deviation is still a deviation, and the spec’s own 756 px arithmetic for that view no longer decomposes as written.
-
The build pack predicted 7 accepted dead-space findings. The run measured 95.
Every one is classified and carries a reason, and 64 of them are a single pane the pack never considered. But an estimate that is off by more than a decade was not doing any work, and the honest reading is that the prediction was a guess with a table around it.
-
Five of the eleven blind-spot rows the pack wrote for itself remain human-only.
tabular-nums is asserted as a CSS property, not as an aligned column of rendered digits. The interior luminance of a plate over the contour ground was never sampled — the plates are opaque by construction and no indeterminate contrast was reported, but that is an argument, not a measurement. Whether four illustrations read as four subjects, whether the overlap sentence reads as a finding rather than a hedge, and whether 4 of 5 reads as a measurement are not mechanical questions and are not answered.
-
One press in the harness carries a 60 ms delay.
A literally-zero-millisecond press — Playwright’s default, and a duration no pointer, finger or switch produces — is swallowed by React Aria’s combo box. The threshold was measured (0 fails; 10, 20, 30 and 50 pass) and the keyboard path is exercised separately with no delay at all. It is still a timing constant in a test, and a reader is entitled to be suspicious of one.
-
One state is asserted after the fact rather than during.
The import parse is atomic, so there is no frame in which a parsing state exists; its story asserts the atomicity instead. If a future change made the parse asynchronous, that story would keep passing while the state it stands for went unreviewed.
-
Motion is never measured.
Every state is settled before capture, by contract. Six projects in, that is a standing gap in the method rather than a fault in this build — but it is true here too, and the 140 ms and 220 ms transitions this product declares are the one part of it no gate has ever looked at.