Skip to content

H1 — Concrete PRO: the reinforced-concrete design flow, drawings and the 3-D rebar viewer - #161

Draft
Batuis wants to merge 37 commits into
feat/pro-steel-familyfrom
feat/pro-concrete-h1
Draft

H1 — Concrete PRO: the reinforced-concrete design flow, drawings and the 3-D rebar viewer#161
Batuis wants to merge 37 commits into
feat/pro-steel-familyfrom
feat/pro-concrete-h1

Conversation

@Batuis

@Batuis Batuis commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #135 (PR21). Base is
feat/pro-steel-family, so the diff here is H1's nine commits and nothing else. Draft
deliberately — this is the working surface for an in-progress redesign, not a review request.

What this continues

PR21 brought metallic structures into PRO and, with them, the shared design surface: the
workflow stages, the stage sections, the design overview, the documents stage. H1 takes the
concrete half of that surface and works on it: reinforced-concrete design in PRO, from the
right-hand panel through the drawings and into the 3-D reinforcement viewer.

The starting point was PR21's own handoff, which left a list of open defects on the concrete
side. Nine commits in, the scope has grown past that list.

Scope

  • Design — the right-hand panel on entering rebar design, stage and sub-section navigation,
    the states before / during / after a design run, and how censuses, results, warnings, refusals
    and provisional outcomes are reported.
  • Drawing export — the documents flow, sheet generation, and review and acceptance of what
    comes out of it.
  • 3-D viewer — the reinforcement workspace end to end: entering and returning, the side rail,
    selection, isolation, filters, opacity, conflicts, legends, empty states, and closing back into
    the workflow.

The viewer is the part that reads least like the rest of PRO today, and making it read like
WorkflowStages / DesignOverview / StageSection / DocumentsSection — and like the --st-*
token system — is an explicit goal rather than a side effect.

What has landed so far

Two behavioural fixes and a shared-token contract:

ad4192e6 The floor families printed slabs.length ?? 0 before the floor pass had classified anything, so a project that had never run it was told it had no slabs. Seven honest states replace the fabricated zero, and a count that is not known is not printed.
23ce3e34 materialFamilyOf inferred the family from the MAGNITUDE of fy, and no production call site supplied the grade lookup. Timber C24 — 24 MPa characteristic bending — was classified as concrete and admitted to the RC pipeline. Classification now follows the declared gradeId, with the magnitude inference kept as a documented fallback.
dfa20d8b Five status tokens: --st-danger-bg, --st-warn-bg, --st-provisional, --st-provisional-text, --st-provisional-bg. Values measured by M1 and verified here — 36 contrast combinations, the tightest at 4.54. No consumer touched by that commit, so the contract can be reviewed before anything depends on it.
695265ba 2b06d834 e67e2dc2 The consumers, then the rest of the concrete surface, then --st-accent — which was standing in for a verdict at eight sites, all of them failing WCAG AA (3.69–4.26 against 4.89–6.01 for the right token).

Raw colour in components/pro/design/: 132 → 64. The remainder is not backlog — it is
recorded, per file, with the reason each literal stays. Two kinds:

  • Contract. three/rebar-scene.ts feeds hex numbers to Three.js materials and cannot read a
    custom property, so the state dots stay literal and a test holds them equal to the scene in
    both directions. A var() in CSS and an 0x in a material can drift apart in silence.
  • No token yet. A modal scrim, an --st-ok-bg that does not exist, and a violet that sits in
    provisional's hue family for a state that is not provisional. Reported rather than papered over
    with a near-match.

How it is verified

Colour is asserted as the resolved value the browser paints, compared against the token
resolved on the same element — .workspace shadows --st-border, so a page-level read is a
different measurement. Each replaced literal is asserted ABSENT by value, because a dim warm
tint and --st-warn-bg are indistinguishable in a screenshot.

Contrast is computed from tokens.css over the composite on every ground a surface can sit on,
not eyeballed and not measured against a raw rgba(), which has no colour of its own.

Where a fixture cannot produce a state, the test says so in its annotations instead of returning
quietly — a conditional that skips silently reads in a report as though it had measured
something.

Gates on every commit: typecheck against the recorded baseline, the full unit suite, the
production build, and the affected E2E at 1280×720 in en/es/pt.

Not in scope for this branch

M1 owns the metallic surface. H1 does not edit ProConnectionsTab, the shared section selector,
the shared catalogues, model.svelte.ts, ProRibbon, or the steel locales; tokens.css only by
the contract above. Where a change would reach a shared file, the file and lines are reported and
the edit stops there.

Batuis added 30 commits August 20, 2026 16:49
…nted

`FloorFamiliesPanel` computed its family counts as `floorRun?.slabs.length ?? 0`,
with the same `?? 0` on walls and `footingRun?.outcomes ?? []` on footings. On a
project that had never run the floor pass, that is `0`, rendered in the family
tab identically to a real zero. So the panel told an engineer their building had
NO SLABS — which reads as a fact about the building and was a fact about the
button.

── A count that is not known is null, never 0 ─────────────────────

`lib/engine/detailing/floor-family-state.ts` is a pure module: data in, state
out, no store and no i18n. Every count it cannot state is `null`, and the markup
renders a reason where the figure would go. Seven states, each from a real
source:

    notRun       run === null
    noElements   readiness.shellCount === 0 · footingCount === 0
    skipped      classified in the family, in neither the designed nor the
                 refused set — derived by subtraction, which is the only honest
                 source, because the run publishes no "skipped" list
    designed     run.slabs[] / run.walls[]
    refused      run.unsupported[] — each entry names its element
    provisional  a result whose maturity is not VALIDATED, or whose own
                 unsupported[] is non-empty
    error        store.lastError

`noElements` outranks `notRun` on purpose: telling someone their model has no
walls is more useful than telling them a pass has not run over walls they do not
have. And a missing maturity record reads as provisional, never as designed —
the cautious default, never the flattering one.

── Two classifications that were disappearing ─────────────────────

`ShellFamily` is `slab | wall | inclined | degenerate`, and this panel had two
tabs. An inclined shell — a ramp, a pitched slab — and a degenerate one, whose
geometry the classifier could not resolve, were classified by the run and then
appeared in NO count: not in `slabs[]`, not in `walls[]`, and not in the
refusals unless they happened to raise one. They now have their own figures and
an explanation of what each means.

── The headline never hides a limitation ──────────────────────────

`kind` is chosen by caution, not by majority. Forty designed panels with one
refusal do not report themselves as clean; a family with any provisional result
says provisional. Every count travels alongside, so caution does not overstate
either.

── What the panel says now ────────────────────────────────────────

Per family: the state as a glyph AND a word (never colour alone), why it is that
state, the scope of the last run — or that there was none, rather than a row of
zeros — and the next action. Plus one sentence that finally distinguishes the two
primary buttons: "Design all" designs the frame and touches no shell or footing;
"Design and detail floors" does the opposite.

── Kept inside the repo's own ceiling ─────────────────────────────

The additions pushed `FloorFamiliesPanel` to 607 lines and
`rc-design-gates.test.ts` caps components at 600. Rather than raise the ceiling,
the state block is its own component: panel 515, card 133. Everything in the
card is presentation over a value the pure module already computed.

── i18n ───────────────────────────────────────────────────────────

31 keys under `design.floor.state.*`, in a CONTIGUOUS headed block in en/es/pt.
Contiguous because piecemeal insertion into these dictionaries is exactly what
produced 64 and then 15 duplicate keys on the last two merges — two independent
insertions at the same places, which git accepts without flagging a conflict.
The block header tells M1 to add `conn.gap.aluminium.scope` as its own block
rather than interleaving.

`locale-parity` guards `design.*` across all FOURTEEN dictionaries, not the three
offered ones, so the keys exist in the other eleven with English text — the same
convention `design.stage.*` already uses. Those locales are not in
`OFFERED_LOCALES` and render English regardless; what the gate prevents is a
feature going missing in silence.

── Tests ──────────────────────────────────────────────────────────

`floor-family-state.test.ts`, 18 unit cases. The load-bearing one sweeps every
unknown path and asserts no count is ever `0` — a single `?? 0` reintroduced
anywhere is the whole defect back. Plus: "nobody looked" against "we looked and
found none", inclined/degenerate not counted as slabs, a null footing check read
as a refusal rather than a zero, and a modelled footing absent from the outcomes
read as skipped rather than refused.

`floor-family-states.spec.ts`, 14 E2E cases at 1280×720: exactly one of a count
or a no-figure marker per tab, the state legible with the glyph stripped, the
reason present, the scope not printing zeros, a next action always, the two
passes distinguished, measured overflow (`scrollWidth ≤ clientWidth`, not
eyeballed), tab roles and `aria-selected`, and all of it in es and pt.

Nothing outside the concrete surface was touched: no solver, no Rust, no Cargo,
no WASM, no analysis, no calculation authority, and no file M1 owns.

Gates: unit 370 files / 6928 tests · build tests 14 · production build 14.3 s ·
typecheck 479 against baseline 479 with no new errors · i18n 161/161 ·
floor-family-states 14/14 · rc-design-gates green.
…concrete

`materialFamilyOf` classifies by a DECLARED grade when it has both a `gradeId`
and a lookup — `if (material.gradeId && lookupGrade)` — and otherwise infers from
the magnitude of `fy`, reading `fy <= 80 MPa` as concrete.

The gradeId always arrived. `ContextModelData.materials` is handed the live
`modelStore.materials`, so real `Material` objects flow through and PR #132's
field was there the whole time. What never arrived was the lookup: no production
call site supplied one, so the declared branch could not run and every material
in the app was classified by a number.

Timber C24 has a characteristic bending strength of 24 MPa. It was classified as
concrete and admitted to the reinforced-concrete design pipeline, where 24 MPa
reads as an ordinary f'c. `en338-c24` has been in `non-metal-grades.ts` with
family `timber` since #132 landed, and nothing consulted it.

── The fix is one argument ────────────────────────────────────────

`design-run.svelte.ts` is the only production caller of
`buildAllMemberContexts`, and it now passes `lookupGrade: catalogueGradeFamily`.

That lookup is M1's, taken as-is rather than reimplemented: two functions
answering "what family is this grade" would eventually be two answers, and this
one is already what the metallic surface uses. Only the module came across —
`engine/steel/grade-family.ts`, 86 lines, importing nothing M1 owns — not M1's
branch. Its contract is `(gradeId) => StructuralMaterialFamily | null`, where
null means "this catalogue cannot answer" and the caller falls back.

`ContextModelData.materials` now declares `gradeId?: string`. Not a blocker —
every call site casts `as never`, so it arrived regardless — but the type said
the declared path could not work when it could, which is why this looked like a
typing defect and was a wiring one.

── This moves members between pipelines, in both directions ────────

`buildAllMemberContexts` keeps only `materialFamily === 'concrete'`, so
classification is the boundary between the concrete pipeline and the metallic
inventory. Declaration replacing inference means:

    fy 30, declares steel     → LEAVES the concrete side  (was: admitted)
    fy 100, declares concrete → ENTERS the concrete side  (was: excluded)
    timber C24                → neither pipeline          (was: concrete)
    aluminium, any fy         → aluminium, not "steel"    (was: steel by fy)

Aluminium was already excluded from concrete, and for the wrong reason: the
magnitude cannot tell it from steel, so the metallic surface filed it under
steel. It is now read as aluminium.

Nothing changed for a correctly classified element: `rc-baseline-digest` still
carries `c6a055ef135d0a71`. No formula, no authority, no solver.

── Tests ──────────────────────────────────────────────────────────

`declared-grade-classification.test.ts`, 17 cases against the REAL catalogue
rather than an injected stub — `steel-excluded-from-rc.test.ts` already proves
the mechanism with a hand-written lookup; what was missing is that the shipped
tables give the right answers and that production uses them. Concrete, timber,
steel and aluminium by declaration; absent and withdrawn gradeIds falling back;
the pipeline admitting concrete and refusing the other three at the SAME `fy`;
and the defect itself pinned — remove the lookup and timber walks back in.

Plus the H1/M1 boundary as tests, so neither branch meets the two crossings by
surprise.

── One conflict with M1, reported rather than papered over ─────────

M1's `grade-family.test.ts` came with the module and 10 of its 12 cases pass
here. The other two assert `emptyReason: 'nonFerrousOnly'` and a
`steel.notice.nonFerrousNotCovered` notice, both from M1's later changes to
`steel-inventory.ts` and `locales/steel/*` — files H1 must not touch.

So that test stays in M1. Editing it here would create a divergent copy of a
file M1 owns, which is the same defect as a duplicated i18n key: two
independent versions that git merges without flagging. H1 covers the contract it
depends on in its own file instead.

Gates: unit 371 files / 6943 tests · build tests 14 · production build 14.9 s ·
typecheck 479 against baseline 479 with no new errors · rc-baseline-digest 5/5 ·
rc-untouched-by-steel 4/4 · generated-models-solve 13/13.
PR20's handoff left this open in its own words: "The `<fieldset>Sheet</fieldset>`
keeps a native legend border, which is the one control group in the panel that
does not match the others."

── What it actually was ───────────────────────────────────────────

`DetailingWorkflow` styled it as `border: 1px solid rgba(143, 163, 179, 0.35)`
with a `legend` carrying no `color` at all, so the legend inherited. Meanwhile
`ProReportDialog` and `ProAutoLoadsDialog` both use `1px solid
var(--st-surface-3)` with the legend in `var(--st-text-2)`. Three fieldsets, one
convention, and this was the one outside it.

The border was not an arbitrary colour either. `--st-hair` is
`rgba(143, 163, 179, 0.22)` and `--st-hair-strong` is `0.38` — the file was
writing the hair token out by hand at a third alpha. All nine of its raw colours
were that: eight slate values at 0.14 / 0.18 / 0.2 / 0.25 / 0.3 / 0.35, plus a
green and a blue of its own. PR20's regulations pass found the same pattern and
said so: "`rgba(143, 163, 179, …)` appeared four times, hardcoded beside the
tokens that mean exactly that."

`DetailingWorkflow` is now at zero raw colours. `.assemblies button.selected`
uses `--st-selected-bg`, which exists and means exactly that — it had been a
translucent slate, which reads as a hover rather than as a selection.

── The audit, and why this one first ──────────────────────────────

The concrete design surface carries 132 raw colours across eighteen files.
Sorted by what this branch may touch:

    concrete-only   BatchEditDialog 3 · ConflictInspector 10 ·
                    DesignFamilyPanel 2 · FloorFamiliesPanel 1 ·
                    FootingCadHandoffPanel 3 · FootingMatPanel 3 ·
                    FootingMatPhysicalPanel 20 · ProvisionalBanner 4 ·
                    RebarScenePanel 13 · RebarStatusPanel 19 ·
                    SectionAdviceDialog 2 · SelectionDetails 9 ·
                    TorsionBanner 4 · VerificationDetail 3
    shared PRO      DesignToolbar 12 · OutcomeBadge 14
    3-D viewer      RebarWorkspace 6 · RebarViewport3D 4

`OutcomeBadge` is referenced by M1's `SteelStatusBadge`, and `DesignToolbar` is
the PRO command row: both need coordination. The viewer is out of scope. So the
named defect was also the one that could be done alone.

Worth recording: `BatchEditDialog` — the file this pass was pointed at first —
was ALREADY tokenised (`var(--st-surface-3)`). Its three raw colours are
elsewhere in the file. The fieldset PR20 meant is the sheet's, in
`DetailingWorkflow`.

── The debt is now measured, not remembered ───────────────────────

`concrete-design-raw-colours.test.ts` records a per-file ceiling that may fall
and may never rise — the same shape as `scripts/typecheck-baseline.json`. A file
absent from the map has a ceiling of zero, so a NEW component must use tokens
from its first line, and `DetailingWorkflow` is pinned at zero so it cannot
regress.

`design-tokens-resolve.test.ts` could not catch any of this: it checks that every
`--st-*` a component REFERENCES is defined, and is blind to a component that
never references one.

── Measured in the browser, not asserted from source ───────────────

`detailing-sheet-fieldset.spec.ts` at 1280×720 compares the RESOLVED border
against the resolved `--st-surface-3`, and the resolved legend colour against
`--st-text-2` — plus a negative assertion against the old literal, which looks
nearly identical on screen and would pass a screenshot. It also checks the
legend is not the inherited body colour, and that the panel does not overflow
sideways.

Three corrections that pass went in while writing it, each worth the note: the
sheet controls only exist once detailing has been generated, so the spec loads a
model and runs `designAll` and is `@slow` for the same reason PR20's sheet tests
are; `getByText(/detailing/)` picks up the ribbon's DISABLED "3-D detailing"
command and waits for it forever, so the disclosure is reached by its testid;
and the first overflow check walked every descendant and flagged 433 of them,
which is not overflow but tables and wells doing what `overflow-x: auto` is for.
It measures the container, as PR20's spec already did.

One locale in the browser rather than three: that the legend key is translated in
en/es/pt is already held by `locale-parity` and `pro-flow-coverage`, which read
the dictionaries directly, and one `designAll` per locale is minutes of suite for
a fact two unit gates already prove.

Nothing outside the concrete surface was touched. The seven floor states and the
gradeId classification are untouched.

Gates: unit 372 files / 6949 tests · build tests 14 · production build 14.7 s ·
typecheck 479 against baseline 479 · raw-colour ceiling 6/6 ·
detailing-sheet-fieldset 6/6 at 1280×720.
… palettes

Concrete bucket 1, the two files carrying the most raw colour: 39 of the 96.

    FootingMatPhysicalPanel   20 → 0
    RebarStatusPanel          19 → 9   (all nine deliberate; see below)
    concrete surface         132 → 102

── Inventoried by role first, and it changed the plan twice ───────

**FootingMatPhysicalPanel** — five slate borders/fills at
`rgba(128,128,128, .15/.18/.25/.3)` and eight status bands from `#5c1a1a`/`#ffe4e4`
(blocking) and `#7a5b00`/`#fff6dd` (advisory): a red and an amber that exist
nowhere else in the application.

    border  →  --st-hair-strong  for the rule that divides the sub-panel,
               --st-border       for the card and the table cells.
               Two documented strengths, used as two strengths.
    surface →  --st-surface-2 for `th`. Not chosen for proximity —
               `DesignTable` already fills a `thead th` with exactly that,
               and one question should not have two answers.
    select  →  --st-selected-bg for `tr.chosen`, which marks the order the
               design RESOLVED to. That is a selection. The 0.18 grey it
               replaces read as a faint band, not as "this row governs".
    status  →  --st-danger / --st-warn, on the RULE, not on the text.

The last one is the only interesting decision, and it is measured rather than
asserted. The tidy version — status hue as the text colour — drops a paragraph
from **10.80:1** to **4.89:1**. It still clears AA, and halving a sentence's
contrast to gain a hue is the wrong side of the trade. `DesignToolbar`'s
`.banner-warn` already had the answer: full-contrast text, status-coloured
border. **14.4:1** now, better than what it replaced.

The badges keep status-coloured TEXT, because there the status word IS the
content at 0.68 rem — the case `tokens.css` says the `-text` variants exist for,
in its own comment. `.badge.geom-MODELED` stays neutral on `--st-surface-3` and
reaches for no status hue at all, which the file's header asks for in as many
words: "One green badge must not be able to" pass for a verified result.

── The nine that stay, and why they are not debt ──────────────────

`RebarStatusPanel`'s seven state dots are a CONTRACT, and
`viewer-design-system.test.ts` already said so: "leaves the state colours alone,
because Three.js owns them." Four are mirrored by value in
`three/rebar-scene.ts` — `0xe0444a` conflicted, `0xd4762a` unreinforced,
`0xa066d3` provisional, `0xffd400` selected — and a material cannot read a
custom property, so aliasing the CSS copies would let the picture and the words
beside it drift apart. `.element.selected` was the trap: `--st-selected` is
vermillion, and taking it would have made the list and the viewport disagree
about which member is selected.

The other three have nowhere to go. `--st-warn` and `--st-danger` are the only
status hues in `tokens.css` and `--st-danger` is already `failed`, so
`unsupported`, `designed-not-modelled` and `refused` would have to share — which
the panel's own rule forbids: "One colour per state, and never two states
sharing one." Frozen whole rather than tokenised by halves, and documented in
place so the next pass does not "finish" it.

── A defect that looked like a token ──────────────────────────────

Six calls of the shape `var(--text-muted, #8b93a3)`. `--text-muted` is real — an
alias on `.workspace` in `RebarWorkspace.svelte` — so the literal never painted
anything, and `design-tokens-resolve` is blind to it either way: it checks that
referenced `--st-*` tokens exist, and this is not one. Correct value, trap of a
form, because it only stays correct while the panel renders inside that one
ancestor.

`viewer-design-system.test.ts` REQUIRES a fallback on those calls, so a bypassed
overlay degrades instead of going unreadable — a contract belonging to
`RebarWorkspace`, which this pass does not touch. Both rules hold at once by not
reaching for those names: `--st-text`, `--st-text-2` and `--st-hair-strong` are
on `:root` and cannot fail to resolve, which is strictly stronger than a
fallback. `.workspace` aliases `--st-border: var(--st-hair-strong)`, so the two
borders paint the same value they painted yesterday.

`#6fa8ff` → `--st-interactive` ("you can click this"), not `--st-focus`, which is
nearer by value and is the ring.

── Stopped and not edited ─────────────────────────────────────────

`tokens.css` has no `--st-danger-bg` / `--st-warn-bg` and no violet. Both would
be additions to a file M1 shares, so no token was invented; the gaps are in the
report instead.

── The tests measure the defect, not the diff ─────────────────────

`concrete-status-tokens.test.ts`, 15 assertions: the contrast arithmetic above,
computed from `tokens.css` by following `var()` aliases to a literal; the named
literals absent outside comments; MODELED still neutral; the Three.js mirror
asserted in BOTH directions, where the existing test checked one of the four;
and the premise of the freeze — four status hues, no violet — so the day a
violet is added this fails and points at the work.

`footing-status-tokens.spec.ts`, 7 at 1280×720 in en/es/pt: resolved colour
against resolved token, plus the negative against `#5c1a1a`/`#7a5b00`, which on
this ground are close enough to a dark well that a screenshot would accept
either. The bands are asserted per CLASS and the coverage is stated, not
implied: `rc-design-qa-8` yields two blocking and no advisory, so the amber
branch is source-only, and a `.first()` over both classes would have read as
though both were checked.

Unchanged and named rather than quietly inherited: blocking vs advisory is
distinguished by colour alone, and was before this too.

`concrete-design-raw-colours.test.ts` now also lists the files AT zero by name,
because "unlisted means zero" is silent about which files those are.

Nothing in `DesignToolbar`, `OutcomeBadge`, `ProRibbon`, `StageSection`,
`DesignOverview`, the viewer rail, the shared toasts, `tokens.css`, `conn.*`,
`profileSelector.*` or the steel locales. The seven floor states and the gradeId
classification are untouched.

Gates: unit 373 files / 6965 tests · build tests 14 · production build 14.7 s ·
typecheck 479 against baseline 479 · css-unused warnings 139, identical before
and after · footing-status-tokens 7/7 · served at 127.0.0.1:4003.
Concrete bucket 1, the three panels named plus one that could not be left behind.

    RebarScenePanel    13 → 11
    ConflictInspector  10 →  5
    SelectionDetails    9 →  5
    TorsionBanner       4 →  0
    ───────────────────────────
                       36 → 21        concrete surface 102 → 87

── The inventory changed what was worth doing ─────────────────────

Most of what the ceiling map counted in these files is not debt. Sorted by role
rather than by count:

    contract, alias fallback   11   `var(--text-muted, #8b93a3)` and one
                                    `var(--st-border, #2c3444)` per panel
    contract, Three.js mirror  10   six state dots, the conflicted count,
                                    the unreinforced rule, the band fill
    actionable                 15   text, borders, hovers, one filled button

`RebarScenePanel` is the reason the fallbacks stay, and it is not a formality:
that panel mounts in `RebarWorkspace` AND in `DocumentsSection`. Outside the
overlay `--text-muted` is not defined at all, so its `#8b93a3` is the value that
paints. The fallback is load-bearing, and `viewer-design-system.test.ts` requires
it for exactly that reason.

So the actionable share of `RebarScenePanel` was two literals, and saying that
plainly is better than tokenising a mirror to make a number move.

── What moved ─────────────────────────────────────────────────────

`RebarScenePanel`'s filled button → `--st-blue` + `--st-text-on-accent`.
NOT `--st-accent`, which `tokens.css` documents as "primary action, brand" and
which this application also fills its DESTRUCTIVE buttons with — `ProReportDialog`'s
`.rpt-btn-danger`, `ProAutoLoadsDialog`'s `.al-error`, `ProMaterialsTab`'s
`.agg-error`. A vermillion "open workspace" would read as a warning. `--st-blue`
is `#2c6cb4` against the `#2b6cb0` that was there, and `--st-text-on-accent` is
exactly the `#fff` it replaces.

`ConflictInspector` had two pinks encoding two severities: `#ffb0b6` on every
header and a brighter `#ff6b74` on the strong when the class is `overlap`. The
DIFFERENCE is what carries meaning, so the base went to `--st-text` and the
emphasis to `--st-danger`. The other way round — base `--st-danger`, nothing
stronger left for overlap — would have merged interpenetration with a spacing
shortfall, which is the distinction the file's own comment exists to state.

Its band keeps `#e0444a` and the 0.14 fill of that same hue: `conflicted:
0xe0444a` in the scene. The SENTENCE took `--st-text`, which is the trade
`FootingMatPhysicalPanel` measured last week — a status hue as body text costs
more contrast than it buys.

── The pair that could not be half-done ───────────────────────────

`SelectionDetails` carried `#f2ddc6`/`#ffbe7a` under the comment "The same amber
the workspace banner uses. One colour, one meaning", and `TorsionBanner` carried
the identical two. Tokenising one would have broken precisely the equality that
comment asserts, so both moved to `--st-text` + `--st-warn` together.

`TorsionBanner` is outside the three files named for this pass. It is concrete-only,
mounts nowhere but `RebarWorkspace`, and M1 does not reference it — so this is a
scope addition inside bucket 1, not a shared-surface edit. Its border also stopped
borrowing `#d4762a`: that is `unreinforced: 0xd4762a`, and a torsion advisory and
an unreinforced bar are unrelated states that happened to share an orange. Only
one of the two is a scene contract.

── Three corrections, each of which had passed ────────────────────

**A locator that measured the wrong element.** `.sel-actions button, .actions
button` resolved to `review-submit` — a "Record review" button belonging to
another component, sitting UNDER the workspace canvas. `hover()` reported the
truth (the canvas intercepts pointer events) and timed out; the sibling test that
only READ a computed style measured that foreign button and PASSED. Both are now
scoped to `rebar-workspace`, and the hover test annotates which button it
measured.

**`doc-3d` does not reveal the Documents panel, it opens the workspace over it.**
The first draft then clicked `rebar-open-workspace` to open something already
open, and waited three minutes on a covered button.

**A contrast assertion with the wrong ground.** `--st-interactive` on
`--st-surface-3` is **4.36:1** — under AA for text. It is used only as
`border-color`, where WCAG 2.1 §1.4.11 asks 3:1, with `--st-text` beside it for
the label. Recorded, along with the fact that the `#6fa8ff` it replaces measured
6.17:1: real headroom was traded for system membership, and that is worth being
able to see rather than discover.

── Coverage stated, not implied ───────────────────────────────────

`viewer-panel-tokens.spec.ts`, 9 at 1280×720, en/es/pt on the Documents route:
resolved colour against resolved token, the negative against each literal, the
`--st-border` shadow proved LIVE inside the overlay (`.workspace` aliases it to
`--st-hair-strong`, so one declaration paints two values and only the browser
knows which), and the hover rule proved to fire at all.

The first version of the torsion test reported "none of the three appeared on this
fixture — source coverage only". True and useless: `rc-design-qa-8` designs
without torsion. `rc-qa-diagnostic` raises the banner — `rebar-toggles.spec.ts`
relies on the same fact — so the amber pair is now measured where it exists,
banner AND selection line. What is still source-only: `ConflictInspector`'s band,
and five of the six state dots (the fixture shows `modelled`). Both annotated in
the run.

`concrete-status-tokens.test.ts` grew to 26: the mirror asserted in both
directions across all three panels, the amber pair held equal through the tokens,
the two contrast bars kept apart, and a hex-shorthand bug in its own luminance
helper fixed — `#fff` sliced two characters at a time yields NaN, and `NaN >= 4.5`
is false, so it failed for the right reason and the wrong cause.

── And a proposal, not a commit ───────────────────────────────────

`docs/handoffs/h1-shared-status-tokens-proposal.md`. `--st-danger-bg`,
`--st-warn-bg` and three provisional tokens, with values derived from the palette
(`--st-red`/`--st-amber` at the `--st-vermillion-dim` alpha), contrast computed
over the COMPOSITE on each real ground, the uses in H1 and M1, the Three.js
constraint, and a six-step migration.

It carries the product recommendation to align provisional with the scene's
`0xa066d3` — with one measured qualification. `#a066d3` does not clear AA as
text: 4.30 on `--st-surface`, 3.77 on `--st-surface-3`. So the alignment is by
IDENTITY, with the two-strength split `tokens.css` already documents —
`--st-provisional` for the dot and the mesh, `--st-provisional-text` `#c08ae6`
(6.46) for labels. Recommending the flat value would have shipped a legibility
regression under the banner of consistency.

`tokens.css`, `DesignToolbar`, `OutcomeBadge`, `SteelStatusBadge` and
`ProvisionalBanner` are untouched. So are `ProRibbon`, `StageSection`,
`DesignOverview`, `RebarWorkspace`, the shared toasts, `conn.*`,
`profileSelector.*` and the steel locales. The seven floor states and the gradeId
classification are unchanged.

Gates: unit 373 files / 6976 tests · build tests 14 · production build 14.7 s ·
typecheck 479 against baseline 479 · css-unused warnings 139, identical before
and after · viewer-panel-tokens 9/9 · served at 127.0.0.1:4003.
Five tokens and the gate that keeps them honest. **No consumer is touched by this
commit** — that is the point: M1 can verify the contract before anything depends
on it.

    --st-danger-bg: rgba(192, 57, 43, 0.14)
    --st-warn-bg: rgba(184, 134, 11, 0.14)
    --st-provisional: #a066d3
    --st-provisional-text: #d8b4ff
    --st-provisional-bg: rgba(160, 102, 211, 0.16)

── Verified, not copied ───────────────────────────────────────────

M1's measured values, checked before being written. All **36** combinations
(3 surfaces × 4 grounds × 3 text colours) clear 4.5:1.

The tightest is `--st-danger` on `--st-danger-bg` composited over
`--st-surface-3`: **4.54**, which passes with 0.04 to spare. That is not a
margin, so the test pins it — anything that darkens `--st-surface-3` or lightens
`--st-red` breaks it and says which.

Two deltas from H1's own earlier proposal, both M1's and both better:

  `--st-warn-bg` at **0.14**, not 0.16. One alpha for both surfaces rather than
  two, and the worst case only moves to 4.76.

  `--st-provisional-text` **#d8b4ff**, not #c08ae6. 9.58 on `--st-surface`
  instead of 6.46 — and it is the value `OutcomeBadge`'s `.badge-provisional`
  already ships, so adopting the token changes no pixel there. Same for
  `-bg` at 0.16: `ProvisionalBanner` and that badge were already right, and
  matching them means the migration is a no-op on the two surfaces that had it.

── A correction to the brief: where 3:1 applies ───────────────────

The ask was "borders and non-text elements ≥ 3:1". For TRAZOS that holds — the
minimum of the set is `--st-provisional` at 3.77 on `--st-surface-3`.

Applied to the tint itself against the ground beneath it, the answer is
**1.09–1.21**, and no alpha fixes it: a tint that reached 3:1 against its own
ground would not be a tint. §1.4.11 is about the boundary of a control and about
meaningful graphics — both covered — not about a decorative fill behind text
whose contrast is measured separately. So the test asserts the three tints stay
BELOW 1.5, which stops someone "fixing" them by darkening them.

── Rule 4, and the metric that had to be thrown away ──────────────

"No component re-mixes a tinted surface that now has a token."

The first version compared each tint's composite against each token's composite
and flagged anything under a distance threshold. It cannot work:
`rgba(238,34,34,.16)` — a red that IS `--st-danger-bg` — sits 11.4 away, and
`rgba(255,255,255,.08)`, plain white with no status hue at all, sits 12.3. No
threshold separates them.

HUE does, with a gap nothing lands in: every true equivalent is within **18.4°**
of a token hue and the nearest false positive is **54.4°**. Achromatic fills —
scrims, white hovers, slate wells — drop out on saturation before hue is
considered, because the hue of a grey is noise. The gap itself is asserted, so if
a future colour lands inside it the rule stops separating and says so rather than
being trusted.

Fourteen exemptions, each with its reason, in two kinds that must not be
confused:

  **contract** (permanent) — `RebarStatusPanel`'s `rgba(255,212,0,.16)` is
  `selected: 0xffd400` and `ConflictInspector`'s `rgba(224,68,74,.14)` is the
  fill of `conflicted: 0xe0444a`. The list and the viewport have to agree.

  **pending** (debt with an owner) — the ten in `OutcomeBadge`,
  `ProvisionalBanner`, `DesignToolbar` and `VerificationDetail` that commits 2
  and 3 remove. A second assertion fails on an exemption for a literal that is
  gone, so the list shrinks instead of rotting.

  **not a band** — the diagnostics command's own fill and its hover level, and
  an inline dialog note. Affordances, not status surfaces.

── Rule 3, by value ───────────────────────────────────────────────

`--st-provisional` is held equal to `three/rebar-scene.ts`'s `0xa066d3`, both
sides parsed to a triplet rather than string-matched: `0xA066D3` and `#a066d3`
are one colour written two ways, and a text comparison would fail on a case
change and pass on `#a166d3`.

The dots stay literal even though the token now exists. A `var()` in CSS and an
`0x` in a material can drift apart in silence; a duplicated literal with a test
comparing them cannot.

── One test failed, exactly as designed ───────────────────────────

`concrete-status-tokens.test.ts` carried `expect(TOKENS, 'no violet exists
yet').not.toMatch(/--st-(violet|purple|provisional):/)` — written last week so
that the day a violet arrived it would fail and point at the work. It did.

Its premise is restated, not relaxed. `unsupported`, `designed-not-modelled` and
`not-evaluated` still have nowhere to go: the vocabulary is five wide now and all
five are spoken for, and `--st-provisional` names a DIFFERENT violet — `#a066d3`
for provisional, not the `#b06ad6` that panel paints `unsupported` with. Two
violets, two states.

Gates: unit 374 files / 7001 tests · build tests 14 · production build 17.0 s ·
typecheck 479 against baseline 479 · shared-status-tokens 25/25 ·
detailing-sheet-fieldset + footing-status-tokens 13/13 at 1280×720 (unchanged,
as expected — no consumer moved) · served at 127.0.0.1:4003.
    OutcomeBadge        14 → 8
    ProvisionalBanner    4 → 0
    DesignToolbar       12 → 10
    concrete surface    87 → 75

`SteelStatusBadge` is untouched, and that is a finding rather than an omission:
its state signal is a `repeating-linear-gradient` hatch — desaturated on purpose,
so stale reads without hue — and its other two tones are a blue and a grey. None
of the three is a tinted status surface, and rule 4 never flagged them.

── The discrepancy this contract existed to close ─────────────────

`FloorFamilyStateCard` sent `provisional` to `--st-warn` while `ProvisionalBanner`,
`RebarStatusPanel`, `OutcomeBadge` and the 3-D scene all painted it `#a066d3`. One
state, two visual meanings.

It now takes the violet, and takes it in TWO strengths, which is not a stylistic
choice: the rule gets `--st-provisional` and the 0.7rem badge gets
`--st-provisional-text`, because the plain token measures 4.30 on `--st-surface`
and 3.77 on `--st-surface-3` — over the 3:1 §1.4.11 asks of a rule, under the 4.5
a small label needs. The `-text` variant is 9.58 and 8.41 on the same two.

── `--st-accent` was reading results as actions ───────────────────

`.badge-fail` and `DesignToolbar`'s `.c-fail` / `.c-sect` were the brand
vermillion — the same fill this application puts on destructive BUTTONS. All
three go to `--st-danger`.

Said plainly because the commit should not claim more than it did: `.c-fail` and
`.c-sect` were ALREADY indistinguishable from each other and still are. This
fixes the token, not that.

── One badge that could not take the obvious tokens ───────────────

`.badge-outcome-SECTION_INADEQUATE` was three hues in one chip: an orange fill,
an accent label, a warn border. The direct translation — warn surface, danger
label — measures **4.09** over `--st-surface-3` and fails AA on the darkest ground
the badge can sit on, which is exactly what rule 1 of the contract forbids. So the
severity rides the border and the words stay at `--st-text`. That also keeps it
distinct from `.badge-warn` (warn on warn) and from `.badge-fail` (danger
throughout), which a straight merge would not have.

── An accessibility defect found by migrating, not by looking ─────

`.badge-provisional`'s border was `#6b4a8f`: **1.76–2.17** against the band it
outlines, well under 3:1. Its fill and label were already the values the token was
derived from, so those two are no-ops by design and the border is the only pixel
that moves — from nearly invisible to 3.13–3.86.

`ProvisionalBanner` is the same story with one addition: the body copy goes from
`#e2d3f5` (11.98) to `--st-text` (13.00), so the sentence sits at full contrast
and the emphasis carries the state. The shape `FootingMatPhysicalPanel` measured
its way into.

`.banner-warn` moves orange → amber, authorised. `rgba(255,102,0,.13)` was a fifth
amber family living in three files and matching neither `--st-amber` nor
`--st-warn` — which is the border this banner already had. Fill and rule now come
from one hue.

── The exemption register shrank because a test made it ───────────

Seven `pending` entries left rule 4's list. They did not leave because I
remembered: the stale-exemption assertion fails on a reason for a literal that is
gone, and it fired the moment the four files were migrated. Three remain, all for
commit 3, plus two permanent `contract` entries and three affordances that were
never status bands.

── Coverage, measured and then improved ───────────────────────────

`status-token-consumers.spec.ts`, 9 at 1280×720, provisional banner in en/es/pt.

The first run passed with four of eight assertions on the "not on this fixture"
path, which is a green suite that proves little. So I probed both models rather
than guessing: `rc-design-qa-8` yields 8 ok badges and nothing else;
`rc-qa-diagnostic` yields **7 warn** and **4 provisional**. The badge tests moved
there, and a provisional-badge test was added — which is what puts the border fix
above under a browser rather than under a stylesheet reader.

Still source-only, and annotated as such in the run: `.badge-fail` (no model
produces one), the toolbar banners, `.c-fail`/`.c-sect`, and the floor card's
provisional state (every family reports `noElements` on both fixtures). A
`continue` in the counts test was replaced, because a loop that skipped both
classes passed while measuring nothing.

Every `var()` is compared against the token resolved ON the element, not on
`:root` — `.workspace` shadows `--st-border`, so a page-level read would be a
different measurement — and each replaced literal is asserted absent by value,
because a dim warm fill and `--st-warn-bg` are indistinguishable in a screenshot.

Gates: unit 374 files / 7001 tests · build tests 14 · production build 17.1 s ·
typecheck 479 against baseline 479 · css-unused warnings 139, identical ·
status-token-consumers 9/9 · footing-status-tokens + viewer-panel-tokens +
floor-family-states 30/30 · served at 127.0.0.1:4003.
    FootingCadHandoffPanel  3 → 0      SectionAdviceDialog  2 → 2
    FootingMatPanel         3 → 0      VerificationDetail   3 → 1
    DesignFamilyPanel       2 → 0      FloorFamiliesPanel   1 → 0
    ─────────────────────────────────────────────────────────────
                           14 → 3      concrete surface    75 → 64

Which is 132 → 64 across the whole effort.

── Two that were only waiting on the contract ─────────────────────

`FootingCadHandoffPanel`'s failure list was `#5c1a1a`/`#ffe4e4` — the third and
fourth appearance of the pair `FootingMatPhysicalPanel` carried, in the same panel
family. It takes `--st-danger-bg` with the rule carrying the severity and the words
at `--st-text` (12.82 at worst), which is the form its sibling measured its way
into.

`VerificationDetail`'s `.advice` was `rgba(255,102,0,.08)` beside a border that was
already `--st-warn` — the fourth site of an orange that was never `--st-warn`. Fill
and rule now come from one hue.

── A token that three files were writing out by hand ──────────────

`rgba(143,163,179, α)` IS `--st-hair` at 0.22 and `--st-hair-strong` at 0.38.
`FootingMatPanel` wrote it at 0.25 and 0.3, `FloorFamiliesPanel` at 0.2 — one token
approximated three ways, which drifts the day the token changes and nothing
reports it.

── A red that only looked like a contract ─────────────────────────

`DesignFamilyPanel` used `#e0444a`, which is also `conflicted: 0xe0444a` in the 3-D
scene. The value matched and the meaning did not: this is the design RESULTS table
and the viewer paints nothing in it. So the token applies here — and reads better,
4.89 at worst against that red's 4.2 — while the identical literal stays frozen in
`RebarStatusPanel` and `ConflictInspector`, which the scene really does mirror. The
test asserts both halves, because the distinction is only real if the frozen ones
stay frozen.

── A near-match refused ───────────────────────────────────────────

`.cert-none` was `rgba(180,120,220,.10)` — a violet, in provisional's hue family,
for a state that means "there is no certificate", on a badge whose border and label
were already neutral. `--st-provisional-bg` was the near match and the wrong
answer: an absence is not a provisional result, and the violet was quietly claiming
it was. It went to `--st-surface-3`, which is what its own border and text already
said.

── Three stayed, and none of them is forgotten ────────────────────

`SectionAdviceDialog`'s two `rgba(0,0,0,0.6)` are a modal scrim and a drop shadow.
`tokens.css` has no scrim token and no shadow token, and three other dialogs write
the same value — a shared gap, not this file's to invent.

`VerificationDetail`'s `.cert-ok` is `rgba(34,204,102,.10)` and there is no
`--st-ok-bg`. The contract shipped two status surfaces deliberately, not four.

`OutcomeBadge`'s `.badge-outcome-SEARCH_EXHAUSTED` is the last `pending` entry left
in rule 4's register, and it is now marked `open` instead: there is no token for
what it means.

All three are declared exemptions with their reason, and the assertion that fails
on a stale exemption is what keeps that register honest.

── Coverage ───────────────────────────────────────────────────────

`footing-status-tokens.spec.ts` grew to 10 on the chain that was already open —
the mat panel's rules against `--st-hair-strong`/`--st-border` with the 0.3
approximation asserted absent, the DESIGNED badge neutral and specifically not
reading as either kind of verdict, and the families table's rule. Annotated: one
direction card and one DESIGNED badge measured.

Source-only and said so in the run: the CAD failure band, which needs an export to
run AND fail, and no fixture forces that.

Gates: unit 374 files / 7007 tests · build tests 14 · production build 16.6 s ·
typecheck 479 against baseline 479 · css-unused warnings 139, identical across all
three commits · footing-status-tokens 10/10 at 1280×720 in en/es/pt ·
served at 127.0.0.1:4003.
Eight sites in the concrete design surface used the brand accent as a verdict.
Not a naming quibble — every one of them failed WCAG AA:

    as text, on the grounds a panel sits on   3.74–4.26   (--st-danger: 4.89–6.01)
    as an opaque fill with --st-text on it    3.69        (the tint form: 12.82 worst)
    as text on an inverted --st-text chip     3.85        (--st-red: 6.04)

So this is the accessibility rule, and the semantics happen to agree with it.

    DesignTable         the `fail` status bar
    DetailingWorkflow   .superseded, .notice.error, .badge-superseded
    RebarEditorColumn   .total.bad, .issue
    RebarEditorBeam     .warn
    RebarSchematics     .bad

The two opaque fills take the tint-plus-rule form the rest of this surface already
uses for a failure. The inverted chip takes `--st-red` rather than `--st-danger`:
its ground is light, and `tokens.css` is explicit that the plain hues are for that
case while the `-text` variants exist for the dark one.

`RebarEditorBeam`'s selector is `.warn` and its content reads "does not fit", so
it took `--st-danger` — the colour now matches what it says rather than what it is
called. The name is left alone; renaming touches markup and tests, and the colour
was the defect.

── Three uses stay, and each says why ─────────────────────────────

`.cmd-cancel` and `.mini-rm` outline destructive controls, which is what the
accent is for — and both are BORDERS, where the bar is 3:1 and it clears.

`RebarSchematics`'s circle fill distinguishes corner bars from intermediate ones.
That is data, not a verdict. Left alone, with the oddity recorded: the same circle
is stroked `--st-danger`, so one object carries two red tokens.

── Made enforceable rather than fixed once ────────────────────────

`shared-status-tokens.test.ts` now lists every surviving `var(--st-accent)` line
with its meaning, fails on an undeclared one, and fails on a declared line that no
longer exists. A new use has to be added to that list, which forces "is this an
action?" to be answered instead of assumed. The three contrast facts above are
asserted too, so the rule is not taken on trust.

── And the five agreed values are pinned ──────────────────────────

The other assertions check PROPERTIES — derived from the palette, alphas
consistent, contrast sufficient — and a property can hold for a value nobody
agreed to. The five are now asserted literally, so changing one is a change to
that list and therefore a conversation:

    --st-danger-bg        rgba(192, 57, 43, 0.14)
    --st-warn-bg         rgba(184, 134, 11, 0.14)
    --st-provisional     #a066d3
    --st-provisional-text #d8b4ff
    --st-provisional-bg  rgba(160, 102, 211, 0.16)

Nothing shared was opened. `ProConnectionsTab` is untouched, as are `tokens.css`
(no `--st-ok-bg`, no `--st-scrim`), the Three.js mirrors, and every literal whose
meaning still has no token.

Gates: unit 374 files / 7011 tests · build tests 14 · production build 15.1 s ·
typecheck 479 against baseline 479 · css-unused warnings 139, identical across all
four commits · shared-status-tokens 29/29 · status-token-consumers +
footing-status-tokens + detailing-sheet-fieldset 25/25 at 1280×720 ·
served at 127.0.0.1:4003.
Sixteen runs: four screens × three languages at 1280×720, plus the four at
1024×700. It MEASURES and does not assert — a phase that exists to find things
cannot fail on finding them. What it found becomes assertions in H1-B..E, in the
files those phases touch.

`docs/handoffs/h1a-concrete-flow-audit.md` is the map.

── Four of my own false positives, killed before reporting ────────

The harness first reported 12 overflows, 25 unnamed controls and a contrast of
1.00. All four were mine, and they are the class of mistake that turns an audit
into noise:

  `.sr-only` is clipped BY DEFINITION — `scrollWidth > clientWidth` is what it is
  for, not what is wrong with it. Twelve findings out of zero.

  The 25 unnamed controls were every checkbox, radio and range inside a `<label>`
  carrying visible text. Named, correctly, by the wrapper. The probe read only
  aria-label / title / textContent OF THE CONTROL; it now implements the name
  algorithm — aria-labelledby → aria-label → wrapping label → label[for] → title
  → own text → placeholder.

  The 1.00 at 0.1px was an SVG `<text>`. `scrollWidth` and `fontSize` on an SVG
  node do not mean what they mean in HTML.

  And the documents screen was measured with `.documents`, which is ONE CARD
  inside the stage — 8 nodes. The real container is `documents-stage`, 24. That
  screen had been reported clean without being looked at.

── The headline: the viewer does not use the application's font ───

    design / detailing / documents    IBM Plex Sans
    workspace                        -apple-system, and no IBM Plex Mono at all

Confirmed in the browser rather than inferred: `insideAppContainer: false`,
`wsFont: -apple-system`, `appFont: "IBM Plex Sans"`, `wsMono: false`.

`App.svelte:1196` mounts `<RebarWorkspace />` as a SIBLING of `.app-container`,
and that container is the only element declaring `font-family: var(--st-sans)` —
its own comment says "One declaration here reaches every descendant that does not
override it." The viewer is not a descendant, so it inherits the system stack from
`index.html:74`.

The mount point is right and should not move: the launcher lives in
`aside.pro-sidebar`, whose fixed pixel width is what made the viewer a few hundred
pixels wide. Escaping the container was deliberate. Escaping the typography was
not — so the fix belongs in `.workspace`, which is H1's file, not in `App.svelte`.

This is the layer `viewer-design-system.test.ts` did not reach. That test exists
because "el visor 3D parece una aplicación distinta" and it aliased the four
COLOUR properties that were falling through to literals. Typography was left. And
`wsMono: false` means no figure in the viewer has tabular digits while every
numeric column in the rest of PRO does, which is the reason `--st-mono` exists.

── Contrast: one systemic, three specific ─────────────────────────

`--st-text-3` (#64798a) clears AA on NO panel ground — 4.03 / 3.74 / 3.62 / 3.28
across bg / surface / surface-2 / surface-3 — and is used as body text at
0.62–0.7rem in ten places. That is not a per-file fix: either it stops carrying
sentences or it changes value, and `tokens.css` is shared.

    4.06   DetailingWorkflow  .progress li.done   --st-text on --st-green
    4.36   WorkflowStages:227 .stage-current .mark  --st-interactive AS TEXT
    3.74   DesignOverview:278,280  .glyph / .label   inheriting --st-text-3

Accessible names, after the probe was fixed: none missing, in all sixteen runs.
Worth saying as plainly as the defects.

── Two heading jumps, in all three languages ──────────────────────

    h3 → h5   DocumentsSection, at "Engineer review"
    h2 → h4   RebarWorkspace / RebarLayersPanel, at "Layers"

── A negative result worth recording ──────────────────────────────

No horizontal overflow anywhere — four screens, three languages, two widths,
container and every non-scrolling child. This partly CONTRADICTS a defect PR20
left reported ("the workflow strip wraps at 1280×720 with a dangling chevron").
Wrapping is not overflowing: the strip can wrap badly while the box fits. That is
a different claim needing a different measurement, and H1-B has to make it before
calling it either true or false.

── And where the viewer is NOT the odd one out ────────────────────

Radii: the design stage paints six — `3px`, `4px`, `50%`, `10px`, `4px 0 0 4px`,
`0 4px 4px 0` — against `--st-radius: 3px` / `--st-radius-lg: 6px`. The workspace
paints three. It is the most consistent of the four screens on that axis, which is
exactly the kind of difference not to "fix".

── What this audit did NOT cover ──────────────────────────────────

Stated because a phase declared closed without looking is worse than one left
open: stage-to-stage navigation, focus on opening and closing the overlay, Escape,
the viewer's interactive states, and synthetically long content. H1-B starts
there.

And the standing lesson: the concrete surface is at 64 raw colours from 132, and
this audit finds four contrast defects and a typography defect in files that are
ALREADY tokenised. The right colour does not imply the right contrast.

── Shared files: reported, not edited ─────────────────────────────

`tokens.css` (`--st-text-3`), `WorkflowStages:206,220,227`,
`DesignOverview:278,280,283-285` — the stage strip and the census are the common
PRO chrome and M1's metallic flow renders inside both. Each is listed in §6 of the
map with its contract and which branch should own it.
`ProjectRegulationsPanel` holds 9 of the 10 `--st-text-3` sites; whether the
metallic surface shares that panel is not yet verified, and is not assumed.

Gates: unit 374 files / 7011 tests · build tests 14 · production build 14.6 s ·
typecheck 479 against baseline 479 · h1a-audit 16/16 · served at 127.0.0.1:4003.
H1-A measured it: `design` / `detailing` / `documents` in `IBM Plex Sans`, the
workspace in `-apple-system`, and no `IBM Plex Mono` anywhere inside it.

`App.svelte:1196` mounts `<RebarWorkspace />` as a SIBLING of `.app-container`,
and that container is the only element in the application declaring
`font-family: var(--st-sans)`. The overlay is not a descendant, so it inherited
the system stack from `index.html:74` and rendered in San Francisco on a Mac and
Segoe on Windows.

**The mount point is not the bug and does not move.** The launcher lives in
`aside.pro-sidebar`, whose fixed pixel width is exactly what made the viewer a few
hundred pixels wide; escaping that container was deliberate. Escaping the
typography was not, and one declaration in `.workspace` fixes it without touching
`App.svelte` — which is what makes this an H1-only change.

This is the layer `viewer-design-system.test.ts` did not reach. That test exists
because "el visor 3D parece una aplicación distinta" and it aliased the four
custom properties falling through to colour literals. Nothing aliases a font.

── And a finding that made it bigger than one declaration ─────────

`.app-container`'s comment says one declaration "reaches every descendant that
does not override it". True of INHERITANCE — but `button`, `input`, `select` and
`textarea` do not inherit a font: every UA stylesheet gives them its own. Measured
inside this overlay: **12 buttons and 13 inputs, all Arial**, inside a panel
already rendering Plex.

    .workspace :global(button), :global(input), :global(select), :global(textarea)
      { font-family: inherit; }

`font-family` and not `font`, which would also reset the sizes these panels set
deliberately. `:global()` — an established convention here, seven uses in
`App.svelte` — because the controls live in five child components and Svelte
scopes a selector to the component that declares it. And scoped to `.workspace`,
which matters: the same defect exists across the application at a far larger
scale (**489 buttons and 23 inputs in Arial inside `.app-container`**, against 15
and 13 in Plex) and that fix is a shared surface, reported below rather than taken.

── Figures ────────────────────────────────────────────────────────

`--st-mono` on the cells whose own authors had already written
`font-variant-numeric: tabular-nums` — `.n`, `.cause-n`, `.tally td`,
`.states strong`, `ConflictInspector`'s `dd`. That declaration asks the CURRENT
font for tabular figures, and the font was whatever the OS supplied; `tokens.css`
is explicit that IBM Plex Mono "is not a style choice here: it has tabular
figures, so a column of results stops jittering as values change."

`SelectionDetails`'s `dd` had neither, though it reports the same kind of measured
value as `ConflictInspector`'s in the same rail. It has both now.

`.n` and `.st` shared one rule, so a count and a translated STATE WORD carried the
same declaration — and `tabular-nums` had never done anything for the word. Split:
the count takes mono, the word stays in the text face. A test checks that
negative, because it is otherwise invisible.

── The 600-line ceiling, respected rather than raised ─────────────

The first draft of this took `RebarWorkspace.svelte` to 631 against a 600 ceiling,
on comments alone — the file was already at 586. Trimmed to 597 with the full
account left in `docs/handoffs/h1a-concrete-flow-audit.md` §4 and in the spec
header, which is where it belongs. The ceiling was not touched; the precedent is
`FloorFamilyStateCard`, extracted rather than exempted when its panel hit 607.

── Shared surface: reported, not edited ───────────────────────────

The app-wide control font. 489 buttons and 23 inputs inside `.app-container`
render in Arial. The fix is one rule —
`button, input, select, textarea { font-family: inherit }` — and its natural home
is `tokens.css` or `App.svelte`, both of which every PRO tab and M1's metallic
surface depend on. Contract: the app declares its face once on `.app-container`
and expects it to reach everything, which is true for inheritance and false for
form controls. Owner: whoever owns the global stylesheet — not H1 unilaterally.

Gates: unit 374 files / 7011 tests · build tests 14 · production build 16.0 s ·
typecheck 479 against baseline 479 · viewer-typography 13/13 at 1280×720 AND
1024×700, plus the rail's labels in en/es/pt · served at 127.0.0.1:4003.
Proposal only. `tokens.css` is untouched and no consumer is migrated.

── The measurement ────────────────────────────────────────────────

`--st-text-3` is `#64798a`, and it clears 4.5:1 on NONE of the four opaque
grounds a panel can have:

    --st-bg 4.03 · --st-surface 3.74 · --st-surface-2 3.62 · --st-surface-3 3.28

The strictest is `--st-surface-3` — the well, the lightest of the four. Confirmed
in the browser by `h1a-audit.spec.ts` at 10.6–11.2px against the real ground, not
computed against an assumed one.

Scope: **489 uses**, 462 of them `color:`. 152 in `components/pro/`, 80 in the
root components, 33 in `edu`, 9 in `App.svelte`, 0 in the landing. **M1 uses it in
three**, all `color:` — `SteelStatusBadge:59`, `SteelPanel:187` and `:214`.

── Why raising the value is the wrong answer ──────────────────────

The minimum relative luminance for 4.5:1 on `--st-surface-3` is 0.2683;
`--st-text-3` has 0.1822. Preserving its hue, the first candidate that clears all
four grounds is lightness **58 %** → `#8396a5`.

`--st-text-2` is at lightness **63 %**.

So a legal `--st-text-3` has to land within five lightness points of
`--st-text-2` — 5.54 against 6.49 on `--st-surface`. At that point the third level
is not a level. The value is not badly chosen: **this palette has no room for
three levels of text on a dark ground, and the third one is occupying a place that
does not exist.**

── Three paths ────────────────────────────────────────────────────

**(A) Redefine the value.** One line, and I do not recommend it: it changes 489
sites at once across `pro/`, the root components, `edu` and `App.svelte` —
including surfaces neither branch reviews — it collapses three levels into two and
a half, and the token would still be called "third level" while being nearly the
second.

**(B) Redefine the MEANING.** WCAG 2.1 §1.4.3 exempts the text of INACTIVE
components. `#64798a` is a perfectly defensible disabled colour; what is not
defensible is that it carries sentences. That means documenting it as such and
migrating the 462 `color:` uses that hold real copy to `--st-text-2` — correct, and
too large for one branch.

Uses already legitimate under (B), in the concrete surface alone: the stale hatch
and bar fill in `DesignTable` (graphics, 3:1), its caret glyph, four borders and a
badge label in `OutcomeBadge`, two borders in `VerificationDetail`, the stale
banner's border, `WorkflowStages`'s BLOCKED stage, and the skipped-family state.

**(C) Scoped to H1 now, with (B) as the direction — recommended.** Migrate only
the sentences and labels of the concrete surface, leave `tokens.css` alone, and
document that the token is not AA for text. Twenty-four sites, seventeen of them in
files H1 owns, listed in §4 of the document.

`ProjectRegulationsPanel` is NOT shared — only `ProRcWorkflowTab` mounts it — and
it holds nine of the sites the audit saw on screen.

── And a larger finding from the same block ───────────────────────

Fixing the viewer's typeface surfaced that form controls do not inherit a font:
inside `.app-container` there are **489 buttons and 23 inputs in Arial** against 15
and 13 in IBM Plex. `.app-container` declares the face once with the comment "One
declaration here reaches every descendant that does not override it" — true of
inheritance, false for `button`, `input`, `select` and `textarea`.

The fix is one rule. Its home is `tokens.css` or `App.svelte`, which every PRO tab
and M1's surface depend on, so H1 solved it only INSIDE the overlay with a
`:global()` scoped to `.workspace` — precisely so as not to take the global
decision alone.

── Shared files, reported and not edited ──────────────────────────

`tokens.css` (`--st-text-3`), `WorkflowStages:186,229`, `DesignOverview:286`,
`DesignToolbar:341,437,460`, `OutcomeBadge:93,96,110,113`. Each with its contract
and owner in §6. Several of them are probably already legitimate under (B), which
is an argument for deciding the meaning before touching any consumer.

Gates: no code changed. typecheck 479 against baseline 479 · unit 374 files /
7011 tests · production build clean.
H1-A measured screens at rest and said so. This measures what it left: focus on
opening and closing, Escape, the workflow strip's wrapping, and synthetically long
content. **Three of the four already worked** — which is worth pinning precisely
because nothing was protecting them.

── Fixed: two heading jumps, in all three languages ───────────────

    h3 → h5  at "Engineer review"   DocumentsSection:171 and :224
    h2 → h4  at "Layers"            RebarWorkspace's h2, then the rail's panels

Both were internal to files H1 owns, and both mattered for the same reason: a
reader navigating by heading could not tell whether the review was a sibling of
the documents or a part of them.

The rail is now `h2` overlay title → `h3` panel → `h4` nested list, across
`RebarLayersPanel` and `RebarStatusPanel`.

**Renaming a tag orphans every selector that named it**, and four did:
`RebarLayersPanel`'s `h4, h5`, its `h4`, its `.tally h5`, `RebarStatusPanel`'s
`h4, h5`, and `DocumentsSection`'s `:global(h5)`. Caught by `npm run build`, which
reports unused selectors — and verified the honest way rather than by counting:
the list of 139 warnings is IDENTICAL selector by selector before and after, so
nothing was orphaned and nothing pre-existing was silently absorbed.

Checked before renaming: `RebarStatusPanel` looked mounted in `ProDesignTab` and
`FloorFamilyStateCard`, which would have made its heading level context-dependent.
Both "mounts" were COMMENTS mentioning it by name. It is overlay-only.

── Verified, not broken: focus and Escape ─────────────────────────

    opener doc-3d → open → focus inside the overlay → Escape → focus back on doc-3d

`RebarWorkspace` already does this with `captureFocus`, and its comment says why:
the opener is a button this overlay COVERS, so without the restore Escape returned
the user to `<body>`. Nothing was checking it. Now something is.

Two behaviours recorded as CORRECT rather than "improved":

  Opening a stage from the strip leaves focus on the strip button. That is right
  for a non-modal disclosure — moving focus into freshly revealed content is what a
  modal owes and a disclosure does not.

  Escape does not close a stage section. A `<details>` is not a dialog and Escape
  has no native meaning on one; the overlay closes because it IS modal. Making a
  disclosure close on Escape would invent a convention and make the two look like
  a pair when only one is standard. Asserted as it is, and raised as a question
  rather than settled in a test.

── Verified: long content wraps, it does not widen ────────────────

A 70-character member label injected into six labels at once, at 1280×720 and
1024×700: the panel's horizontal overflow stays at 0 and the labels grow taller.
Both halves asserted, because a panel that fits because its text was clipped away
would pass the first one alone.

── Confirmed and NOT fixed: the dangling chevron ──────────────────

PR20 reported "the workflow strip wraps at 1280×720 with a dangling chevron". H1-A
found no overflow and said that was a different claim needing a different
measurement. Here it is, and the claim is **confirmed**:

  `ol` is `flex-wrap: wrap`, and at 1280, 1024, 900 AND 820 it makes the same two
  rows — five stages, then `stage-documents` alone. The chevron comes from
  `.stage:not(:last-child)::after`, so `stage-detailing` draws one and it is the
  last thing on row 1: a `›` pointing at the end of the line.

  It is width-INDEPENDENT. The strip lives in the fixed-width PRO sidebar, so this
  was never a narrow-viewport problem, which is why looking at 1280 alone made it
  read as one.

`WorkflowStages.svelte` is shared PRO chrome — M1's metallic flow renders inside
the same strip — so H1 does not edit it. The test is marked `test.fail()`: the day
someone fixes it, Playwright reports an unexpected PASS and the marker comes off. A
test asserting the defect would have to be inverted instead.

`test.fail()` INSIDE the body, not at describe scope. The first version put it at
describe scope, where it marks every test in the block, and Playwright duly
reported "Expected to fail, but passed" against the sideways-scroll test that
passes and should. One misplaced line turned a green assertion red and would have
hidden the real one.

Gates: unit 374 files / 7011 tests · build tests 14 · production build 15.6 s ·
typecheck 479 against baseline 479 · css-unused warnings identical selector by
selector · h1b-panel-navigation 13 at 1280×720 and 1024×700 in en/es/pt, with the
one expected failure · viewer-typography + h1a-audit 29/29 · 127.0.0.1:4003.
Path C, as approved. The token keeps its value; its MEANING narrows to inactive
and disabled text, glyphs and rules — which is what it is good for, and what
WCAG 2.1 §1.4.3 exempts. 28 sites carrying copy moved to `--st-text-2`.

`tokens.css` gains a comment and **not one changed declaration** — verified by
diffing the file for `--st-*` lines, of which none moved. The comment records the
arithmetic that makes this a reservation rather than a preference: 4.03 / 3.74 /
3.62 / 3.28 on the four grounds, and the first hue-preserving lightness that
clears 4.5 is 58 % while `--st-text-2` is at 63 %. A legal third level lands five
points from the second and stops being a level.

Migrated: `ProjectRegulationsPanel` (9 of the sites the audit saw on screen),
`ChangedMembersPanel`, `BatchEditDialog`, `DesignFamilyPanel`, `DesignTable`,
`DesignFilterBar`, `FloorFamilyStateCard`, `RebarEditorColumn`,
`RebarEditorBeam`, `RebarSchematics`, `VerificationDetail`, `ProDesignTab`. All
twelve verified concrete-only first: mounted by `ProDesignTab`,
`ProRcWorkflowTab` or `FloorFamiliesPanel`, none by anything metallic.

Deliberately left on the token, each with the reason in place: `DesignTable`'s
`.caret` (a disclosure triangle — §1.4.11 asks 3:1 and it measures 3.74), its
stale hatch and unavailable bar fill, and two borders in `VerificationDetail`.

── Two defects the browser found that the source could not ────────

**Text on a green FILL cannot clear AA.** `DetailingWorkflow`'s
`.progress li.done` put `--st-text` on `--st-green`: **4.06**, and no text colour
fixes it — dark ink on that fill is 4.18. `tokens.css` says why: the plain hues
are "for fills, rules and figures, where area carries the meaning", not for a
0.7rem label on top of one. `done` now keeps the base well and signals with
`--st-ok` at 4.88, full opacity against the 0.6 of the steps still to come, with
`[aria-current='step']`'s outline still marking the current one.

**A rule that lost its markup — and it was mine.** `.no-n` is declared in
`FloorFamilyStateCard` and the `<span>` lives in `FloorFamiliesPanel`. The rule
went across when that component was extracted to get the panel under the 600-line
ceiling; the markup stayed. Svelte scopes a selector to the component that
declares it, so it has matched nothing since — and the marker whose entire purpose
is to print a DASH instead of a fabricated zero has been rendering at full size
and full contrast, reading as primary content rather than as an absence. Moved to
the file that owns the markup.

The build had been reporting both `.no-n` and `.st` as unused the whole time.
`.st` was dead too — the markup uses `st-badge` — and is gone. Unused-selector
count 139 → **137**, and the diff is exactly those two lines. Left alone at first
because it kept my regression metric stable, which is the wrong reason to keep
dead code.

── The gate measures legibility, not declarations ─────────────────

`concrete-copy-contrast.spec.ts` walks every text node in `.pro-panel` and
computes its ratio against the resolved background of its nearest opaque
ancestor — **2576 nodes** per run, three languages × two widths. A source test can
prove a declaration says `--st-text-2`; it cannot prove the element is legible,
because the ground is whatever ancestor happens to paint one.

Three exemptions, listed BY NAME with their reason rather than as a blanket
"ignore anything under 4.5", so a new under-AA element still fails: the caret, and
`DesignOverview`'s census glyph and label plus `DesignToolbar`'s command-group
labels — the last three inherit `--st-text-3` in shared PRO chrome that M1 renders
through, so H1 does not touch them.

Escape stays as it is: the overlay closes because it is modal, a `<details>` does
not because it is not. No new convention invented.

Gates: unit 374 files / 7011 tests · build tests 14 · production build 15.6 s ·
typecheck 479 against baseline 479 · unused CSS 139 → 137, diff exactly the two
dead selectors · concrete-copy-contrast 8/8 at 1280×720 and 1024×700 in en/es/pt ·
served at 127.0.0.1:4003.
`WorkflowStages.svelte` and `DesignOverview.svelte` are untouched — zero H1
commits against either, verified with `git log -- <path>`. All three fixes are one
line each, and all three are seen by M1 because the metallic flow renders through
the same stage strip and the same census.

── The dangling chevron, and two corrections to how it was described ──

`ol` is `flex-wrap: wrap` and `.stage:not(:last-child)::after` is the `›`. Grouped
by `top`, the DOM says:

    1280 / 1024 / 900 / 820   row 1: model demands check design detailing
                              row 2: documents
                              scrollWidth − clientWidth: 0     height: 50px

**There is no overflow** — the box fits at all four widths. Wrapping is not
overflowing, and looking for it as overflow is why H1-A first reported it as not
reproducing.

**It is width-INDEPENDENT.** The same two rows from 1280 down to 820, because the
strip lives in the fixed-width PRO sidebar at 539px of client. It was never a
narrow-viewport problem; measuring only 1280 made it look like one.

`detailing` is not the last child, so it draws a chevron, and it is the last thing
on row 1 — a `›` pointing at the end of the line.

Minimum change: move the glyph to `.stage + .stage::before`. A separator on the
FOLLOWING item travels with it when the row breaks, so `documents` takes its
chevron down to row 2 and row 1 ends on an item. Same glyph, same sequence
reading, other side of the joint. `nowrap` + `overflow-x` is rejected in the
document: six stages in 539px would mean horizontal scrolling in a navigation
element, which is worse than two rows.

── Contrast in the strip: two defects, and one that is NOT one ────

    186   .mark base            --st-text-3 on --st-surface     3.74   under 4.5
    227   .stage-current .mark  --st-interactive AS TEXT        4.36   under 4.5
    229   .stage-blocked button --st-text-3 on --st-surface     3.74   FINE — 3:1

Line 229 is a blocked stage: an inactive control, whose text §1.4.3 exempts
explicitly. It is exactly what `--st-text-3` was just reserved for, and the
document says to leave it AND document why, or the next reader "fixes" it.

Line 227 is the trade H1 has now measured three times on this branch: the state
rides the RULE and the words go to full contrast. `--st-interactive` clears the
3:1 §1.4.11 asks of a border at 4.36 and does not clear 4.5 as text. The hue does
not change, only the role.

── The census glyph ──────────────────────────────────────────────

`.glyph` and `.label` declare no colour; they inherit `--st-text-3` from
`.tone-muted` at line 286. Measured at 11.2px in all three languages and both
widths, reading "not verified" / "sin verificar" / "não verificado" — which is
precisely the state a reader should not have to squint at.

The other three tones of the same census already pass, because they use the
`-text` variants: `--st-ok` 4.88, `--st-warn` 6.60, `--st-danger` 4.89.
`tone-muted` is the only one that took a text token that is not AA. One line.

── Order, and the tests that flip ────────────────────────────────

Census first (one line, then remove `'glyph'` and `'label'` from
`ALLOWED_BELOW_AA` so the gate starts covering it), then the strip's two contrast
lines, then the chevron last because it is the only one with layout risk.

Both exemptions in `concrete-copy-contrast.spec.ts` are listed BY NAME with their
reason, precisely so that removing them is the last step of the fix rather than
something forgotten. And `no wrapped row ends in a chevron pointing at nothing` is
`test.fail()`: when the chevron is fixed, Playwright reports an unexpected PASS and
the marker comes off in the same commit.

`DesignToolbar:341` and `:437` are the same class of defect and are left out on
purpose: the command row has ten raw colours and two hover levels on its
diagnostics command, and deserves its own review rather than a loose contrast
patch.

Gates: no code changed.
…s states

H1-A reported this stage clean. It had measured `.documents` — ONE CARD inside the
stage, 8 nodes — and had never built a document, so "clean" meant "not looked at".
With `documents-stage` and a real document it is 18 text nodes, and there are four
findings.

`docs/handoffs/h1c-documents-audit.md` is the audit.

── The chain works, and one of my own readings was wrong ──────────

state → export → review → acceptance → issue. The document is built LAZILY, by the
first export: `doc-xlsx` downloads **`detailing-rev1.xlsx`** and the panel picks up
`doc-readiness` and "Revision 1". Deliberate, and it works.

I first reported a silent no-op in `doc-report`. **Wrong.** My probe waited for a
download and that action uses `window.open` + `print()`, and I read the panel state
BEFORE exporting, so I saw "No document built yet" and concluded exporting did
nothing. The code also has the error branch I had read as missing.

── The defect, and it loses data ──────────────────────────────────

`detailing.svelte.ts:1380` calls `retireDocument()` **before** `applyReview`
decides. So with `Record review` having no `disabled` and no explanation:

    before   doc-readiness = 1, doc-revision = "Revision 1"
    click    review-error = "There are provisional calculations without express…"
    after    doc-readiness = 0, doc-maturity gone, superseded-docs = 1

A click that accomplished nothing superseded the document the user had just built,
and they have to export again to get it back.

A SUCCESSFUL review retires it too, and that is correct — the store's own comment
says a review changes the readiness a document may claim. The refusal path is the
defect, not the success path.

**Fixed at the component level.** `review-submit` is now disabled while any reason
stands, and the reasons are shown in `review-blockers` using **the same three
sentences the store would give afterwards, from the same locale keys** —
`notConstructible` (mirroring `assembly.ts:481`), `engineerRequired`,
`provisionalOutstanding`. Not a new set of rules: the same ones, said before the
click. Which is the principle the note under `issue-submit` already stated, applied
to the control it had forgotten.

Measured after: disabled with two reasons → name → one reason → acknowledgements →
enabled, document intact, review succeeds, `issue-submit` opens.

**Not fixed: the ordering in the store.** Fourteen components read
`detailing.svelte.ts`, four of them frozen files. No steel file uses it, but
reordering `review()` changes behaviour for every caller. Contract, minimum change
and owner are in §3. With the gating, the refusal path is unreachable from this
panel; it still exists for any other caller.

── A gap my own change put on screen ──────────────────────────────

Stating the reasons BEFORE the click made the spec's language tripwire fail in
Portuguese: `review-blockers` read "The reviewing engineer must be named…". The
five `detailing.review.*` keys existed in `en` and `es`; `pt` had **one of five**.
A Portuguese user was refused in English.

Neither gate could catch it: `locale-parity` filters `k.startsWith('design.')`, and
`pro-flow-coverage` scans components and `lib/engine/detailing` while these keys
are minted in the STORE — which the store says itself: "The store is the locale
boundary, so the engine's refusal is translated HERE."

And the real scope, which is not this branch's: counting keys that `en` and `es`
both have and `pt` does not —

    landing 317 · cad 254 · detailing 158 · footing 99 · loads 90
    report 52 · codes 48 · pro 33 · TOTAL 1176

`pt` is an offered locale. 1176 missing keys is a project problem, reported and not
touched. The four `detailing.review.*` are fixed here, because this change is what
puts them on screen in a file H1 owns. 5 of 5 in all three offered locales.

Recommended and left for its own block: widen `locale-parity` past `design.`, and
add `lib/store` to `pro-flow-coverage`'s scan. Both one-liners, both will fail
loudly the first time.

── Legibility and fit are different claims ────────────────────────

18 text nodes in `documents-stage`, `en`/`es`/`pt` at 1280×720 and 1024×700: zero
horizontal overflow and **not one text under its contrast bar**. Said as plainly as
the defects.

But fitting is not informing. With a document built, the stage shows readiness,
revision, maturity, four buttons and the review form. It does not show what was
exported, a sheet list, or a preview. What is missing is information, and no layout
measurement detects that.

── What this did NOT cover ────────────────────────────────────────

`doc-error` and `detailing.doc.noCoordinated` were never exercised in a browser —
this fixture always builds. The XLSX was verified by filename, not opened; the DXF
not at all. `superseded-docs` appears and its content was not measured. Focus
INSIDE the stage after an export or a review. Loading states, of which I found none
because the exports are synchronous here. And `SheetPreview` — 271 lines that do
not appear on this stage, and I did not find where they are reached from.

Gates: unit 374 files / 7011 tests · i18n 114 tests · build tests 14 · production
build 18.7 s · typecheck 479 against baseline 479 · unused CSS 137, unchanged ·
h1c-documents-flow 17/17 · concrete-copy-contrast + h1b-panel-navigation +
viewer-typography 34/34 · served at 127.0.0.1:4003.
Authorised, and one line moved. `retireDocument()` ran BEFORE `applyReview`
decided, so `review()` could supersede the document and then return `false`. It now
runs after the `if (!r.ok)`.

The retirement itself is kept and is correct — the store's own comment says a
review changes the readiness a document may claim, so the previous one is no longer
current. Only the order was wrong.

── The regression test, and proof that it is one ──────────────────

`e2e/h1c-documents-flow.spec.ts` → "a refused review does not cost the document".
Verified by reverting the store to the old ordering and re-running: it fails with

    Error: the document survives
    expect(locator).toBeVisible() failed — element(s) not found

and passes with the fix. A test that had not been shown to fail on the defect would
not be a regression test.

It goes through `__stabileoActions.reviewAssembly` and not `__stabileo` — the split
is deliberate and documented in `e2e-hooks.ts`: "`window.__stabileo` is READ-ONLY:
queries only, frozen, no state setters." The first version reached for the query
object and got `not a function`.

And it has to go through a hook at all, because the gate added earlier in this block
makes the refusal UNREACHABLE by clicking. Both halves are asserted: the gate stops
a user reaching it, this proves the store underneath is no longer destructive if
anything else does.

── Why no unit test covered this ──────────────────────────────────

Mine is the first unit test in the tree to call `detailingStore.review()` at all,
which is how the ordering survived.

The two I added to `footing-document-slice.test.ts` do not prove it, and they say so
in place rather than passing quietly. `detailingStore.assemblies` is populated by the
MEMBER detailing run; that file's footing-only fixture leaves it empty, so
`review()` returns `false` from `if (!selected)` with **no `lastError`** — a refusal
for entirely the wrong reason, which the first draft of the test recorded as a pass.
They now assert the selection explicitly, and where the fixture cannot provide one
they state that and stop.

That is also why the positive control — a recorded review DOES retire the document —
is conditional there and unconditional in the E2E: a test that proves half a pair
and reads like the whole is worse than one that admits which half it got.

The fourteen consuming surfaces are unchanged; the two existing supersession tests
cover geometry edits (`updateFooting`, `updateSoilProfile`), a different call path.

Gates: unit 374 files / 7013 tests · build tests 14 · production build 15.8 s ·
typecheck 479 against baseline 479 · h1c-documents-flow 18/18 · 127.0.0.1:4003.
── Where `SheetPreview` is reached from ───────────────────────────

`DetailingWorkflow.svelte:266`, in the **Detailing** stage — so the drawing preview
lives one stage BEFORE the exports that emit it. A reader standing in Documents
deciding whether to export does not have the sheet in front of them.

Not moved and not duplicated. Moving a preview between stages is a flow change, not
a fix, and copying the component would create the second renderer its own comment
exists to avoid ("the SAME `detailingStore.sheetSvg` … not a second renderer").
Recorded in the audit as the first of three things that still make this stage thin.

── What it now shows, all of it already in the model ───────────────

The stage carried three states and no content: readiness, revision, maturity. A
reader could not tell whether "Revision 1" covered one assembly or forty, which
codes it was verified against, or whether it carried assumptions.

    doc-count-assemblies      d.assemblies.length
    doc-count-certificates    d.certificates.length
    doc-count-clauses         d.refs.length
    doc-count-assumptions     d.assumptions.length   (only when there are any)
    doc-regulations           d.regulations, with the editions the verification used

Not one figure is new or computed — every one is something `DocumentModel` already
declared and the panel did not print. And no fabricated zeros: the block renders
only once a document exists, asserted in BOTH directions, so before an export there
is not a single count rather than a row of noughts.

Four locale keys, in `en`, `es` and `pt`.

── Superseded, focus, and two good results ────────────────────────

`superseded-docs` verified with real content: a recorded review retires the current
document and the list KEEPS it, named by revision. The same principle
`footing-document-slice.test.ts` states about the renderer — a project that cannot
show what it previously issued cannot answer the only question that matters after
something goes wrong.

Focus, measured: after an export it stays on `doc-xlsx` even though the panel
re-renders around it, and after a recorded review it stays on `review-submit`. The
case worth checking was the opposite — a control that becomes `disabled` drops focus
to `<body>`, which for a keyboard user is a dead end whose next Tab restarts at the
top of the document. It does not happen here, and the assertion is there so a future
change to the gating cannot introduce it unnoticed.

A speculative comment about that dead end was replaced with what was measured.

── Still not covered, and not faked ───────────────────────────────

`doc-error` / `detailing.doc.noCoordinated` is still unexercised in a browser:
`buildDocument` always succeeds on this fixture, and producing detailing that is
generated but NOT coordinated is not something I found a route to. No coverage
invented.

File CONTENTS are unverified. The XLSX is checked by filename; I did not open it.
Whether it has the expected sheets, whether the DXF is valid R12, and whether the
report prints what it claims are three assertions that need to read the blobs rather
than the UI. `exportReport` uses `window.open` + `print()`, so the PDF route is
partially covered — the popup is confirmed, the output is not.

And **what was exported is not recorded anywhere**: the store has no `lastExport` or
equivalent. Showing it would need new store state, and I do not invent progress the
store does not support. It is the most useful missing piece on this stage and the
only one that cannot be added without deciding a contract first.

**Documents is not finished.** It fits, it is legible, and it now says what it
contains. What it lacks is not layout: it does not show the sheet it is about to
export, it does not record what it exported, and its export-error state has never
run.

Gates: unit 374 files / 7013 tests · i18n 114 · build tests 14 · production build
clean · typecheck 479 against baseline 479 · unused CSS 137, unchanged ·
h1c-documents-flow 23/23 at 1280×720 and 1024×700 in en/es/pt · 127.0.0.1:4003.
… sees it

Report only. Nothing changed by this document, and deliberately kept out of H1-C so
it does not become that block's accidental scope.

── Scope ──────────────────────────────────────────────────────────

Fourteen dictionaries. Taking the keys `en` and `es` both have as the base:

    pt    1172 missing        ← one of the three OFFERED locales
    de    2565
    ar fr hi id it ja ko ru tr zh    2570 each

`pt` by namespace: `landing.` 317, `cad.` 254, `detailing.` 154, `footing.` 99,
`loads.` 90, `report.` 52, `codes.` 48, `pro.` 33, `ribbon.` 26, and 79 across
sixteen smaller ones.

`landing.` and `cad.` are half of it. The three namespaces an engineer sees while
working — `detailing.`, `footing.`, `loads.` — are **343**.

── Why nothing catches it ─────────────────────────────────────────

**`locale-parity.test.ts:63`** filters `k.startsWith('design.')`. It does compare
all fourteen dictionaries, which is why `design.*` is complete everywhere — that
gate is what forced 31 keys into all fourteen in an earlier block. Everything else
was never compared.

**`pro-flow-coverage.test.ts:50`** scans components and `lib/engine/detailing`. This
family of keys is minted in the STORE, and the store says so about itself: "The
store is the locale boundary, so the engine's refusal is translated HERE. It used to
arrive as a Spanish sentence built inside a pure module, which told an English-locale
user why their review was refused in the wrong language."

So moving the translation into the store was CORRECT — it fixed a real defect — and
it left the keys in a layer the gate does not look at. One fix created the other's
blind spot.

── Proposal, in the order it has to happen ────────────────────────

1. Widen `locale-parity` with a NAMESPACE MAP, not a jump to "everything". A filter
   that covers all of it fails with 2570 entries per dictionary and cannot be read.
   Guarded in all fourteen: the working surface. Guarded in the offered three: the
   rest of the app. Explicitly out: `landing.`, which has its own translation cycle.
2. Add `lib/store` to `pro-flow-coverage`'s scan. One line, will fail the first
   time, and what it finds is exactly this class of gap.
3. Then translate by namespace: the working surface (343) first, then chrome and
   outputs (159), then `cad.` as its own block because it is drawing vocabulary, then
   `landing.`.
4. Decide the eleven unoffered dictionaries. Today they are eleven files that look
   like support and are not.

Steps 1 and 2 must come before 3, or the translating proceeds with nothing measuring
it.

Both gate files are shared — widening either affects H1, M1, `edu` and the landing
at once — so none of the four steps is H1's alone. H1 added eight keys total, all in
`en`/`es`/`pt`, both times because a change of its own put them on screen.
… button

H1-A measured the overlay at rest and said it had not measured the interactive
states. This is those. Most of what it found was working; one thing was a keyboard
dead end.

── The defect ─────────────────────────────────────────────────────

`Isolate` and `Clear isolation` were an `{#if}/{:else}` pair. Pressing one flipped
the condition, so Svelte DESTROYED the button that had just been pressed and created
the other — the focused element left the DOM and focus fell to `<body>`. Measured
twice: once on isolating, again on clearing. For a keyboard user that is a dead end
whose next Tab restarts at the top of the document.

One node now swaps its label, action and testid. The two testids are unchanged
because they name the STATE and existing specs read them that way.
`{@const}` may only be an immediate child of a block, so the flag is a `$derived`.

Measured after: focus lands on `rebar-clear-isolation` and then back on
`rebar-isolate`, and never on `body`.

── Four things that were fine, and three probes of mine that were not ──

**Isolation does reach the scene.** The first probe counted member rows — 9 before,
9 after — and read that as isolation doing nothing. The member list is not what
isolation filters, and it should not be: the list is how you choose what to isolate,
so emptying it would take away the way back out. The census is the instrument, and it
changes.

**`rebar-layer-conflicts` and `rebar-hide-unreinforced` change nothing here because
there is nothing to change.** `rc-design-qa-8` draws 200 column bars, 226 beam bars,
8 solids, and — measured — zero conflict markers and nothing unreinforced. They are
not dead controls. The test asserts the PREMISE (`markers === 0`), so a future
fixture that does produce conflicts fails it and forces them to be exercised
properly rather than annotated away.

**Opacity moves no mesh count**, because it is a material property. The census was
the wrong instrument, not the slider a broken one.

**`rebar-rail-toggle` is `display: none` at 1280×720** — a 0×0 box. The rail cannot
be collapsed on a desktop, which is a product decision. It cost a five-minute
timeout to find, because `click()` waits for a hidden element forever, and it is
recorded so the next person does not pay that again.

── Escape, return, and the empty state ────────────────────────────

Escape closes the overlay, focus lands back on `doc-3d`, and the workflow is where
it was. `rebar-workspace-close` works. **`rebar-back` does not exist**: the route out
is there twice over, what is missing is a labelled "back to the workflow"
affordance — a content decision, not made here.

Opening the viewer with no detailing at all: `doc-3d` is enabled and the overlay
opens, consistent with the documents stage where the exports build on demand. What
comes up is `rebar-empty-families` and NOT `rebar-workspace-empty` — the rail names
the families that have nothing rather than the workspace claiming to be empty.
Asserted as it is, because the two messages mean different things and only one of
them is true.

Gates: unit 374 files / 7013 tests · build tests 14 · production build 14.9 s ·
typecheck 479 against baseline 479 · unused CSS 137, unchanged ·
h1d-viewer-audit 9/9 · viewer-typography + viewer-panel-tokens + rebar-toggles
33/33 · 127.0.0.1:4003.
…uce them

Measured first, on unmodified production. No engine or solver change, and no
manufactured data.

    rc-design-qa-8      0 conflicts ·    0 markers · modelled 9
    rc-qa-diagnostic   68 conflicts ·   68 markers · modelled 23 · provisional 5
    pro-edificio-7p  1318 conflicts · 1310 markers · modelled 194 · provisional 6 · failed 6

So H1-D's annotation — "nothing to filter on rc-design-qa-8" — was a fixture limit
and not a defect, and `rc-qa-diagnostic` lifts it in 4 s rather than the 7-storey
building's 20 s. That building is used once, for the `failed` state only it has.

── What is now exercised rather than asserted from source ─────────

The conflicts layer, on real markers: 68 drawn → 0 → 68 again.

`doc-conflicts`, a documents state H1-C never reached because `rc-design-qa-8` has
none: the block renders with a real count and the readiness falls to a draft, which
is the honest consequence of unresolved conflicts.

The `provisional` and `failed` rail states on members that actually have them, with
their dots compared by value against the scene's `#a066d3` and `#e0444a` — the
mirror contract confirmed on real states rather than on a stylesheet. And the state
WORD beside the dot, which is what makes the colour support rather than the signal.

── What no fixture reaches, asserted as absent rather than skipped ──

**`refused` / unreinforced: zero in all three.** Asserted, so the day a fixture
produces one this fails and the unreinforced filter and the `.unreinforced` block
get exercised instead of staying source-only.

**`doc-error`: all three build a document successfully.** It needs detailing that is
generated but NOT coordinated, and I did not find a route to that.

**`ConflictInspector`: unreachable from a test.** It renders from
`selection.conflict`, which only `rebarWorkspace.selectConflict` sets, and the only
UI route is clicking a marker in the WebGL scene — raycast against the canvas. There
is no `selectConflict` in `e2e-hooks.ts`. The test asserts the markers ARE there and
the inspector is NOT, with the reason, rather than leaving a silent hole. The
one-line fix is a hook, proposed in the handoff.

── And the close button says where it goes ────────────────────────

It read "Back to the model". Closing returns to the design stage it was opened from
— Documents — not to the model editor, so the label pointed somewhere the button
does not go. Relabelled to "workflow" in the three offered locales.

`rebar-back` still does not exist, and that is the point: one control relabelled,
not a second route added. Escape and this button remain the only two ways out, and
the test now clicks the button and checks it returns focus to `doc-3d` exactly as
Escape does.

Gates: unit 374 files / 7013 tests · i18n 114 · build tests 14 · production build
clean · typecheck 479 against baseline 479 · unused CSS 137, unchanged ·
h1e-conflict-states 6/6 · h1d-viewer-audit 9/9 · 127.0.0.1:4003.
`bec8afac` shipped with `rc-design-gates` red: the eight-line comment on the close
button took `RebarWorkspace.svelte` to 606 against a ceiling of 600. I ran the gates
and committed in the same step, so the failure went out with the commit instead of
before it. My error, and the fix is one commit later than it should have been.

The comment is now one line and the file is 598.

Worth stating rather than fixing silently: this is the SECOND time in this branch
that a comment alone pushed this file over. It was 586 when H1 started, so it has
about a dozen lines of headroom and every explanatory note spends them. The
precedent for that is extraction — `FloorFamilyStateCard` came out of
`FloorFamiliesPanel` at 607 rather than the ceiling being raised — and this file is
the next candidate. Not done here: an extraction in the middle of an audit block
would mix a refactor into a measurement.

Gates, run to completion this time: unit 374 files / 7013 tests · build tests 14 ·
typecheck 479 against baseline 479 · production build clean · h1d-viewer-audit and
h1e-conflict-states green.
Two handoffs, no code.

── The export record ──────────────────────────────────────────────

Documents shows readiness, revision, maturity, contents and regulations, and **not
one exported file**. There is no `lastExport` or equivalent in the store: the three
export functions call `currentDoc()`, write a blob, and tell nobody.

What that costs in practice: someone who exported the DXF, edited a footing and came
back has no way to know the file in their folder no longer matches. The document
model knows it was superseded — `supersededBy`, `supersededDocuments` — and nothing
connects that to the files that left.

`ExportRecord` is proposed as SEPARATE state, not a field on `DocumentModel`, and
that is the decision the whole thing turns on. The document model is serialised and
read by three renderers, so a new field means versioning the model and deciding what
an old `.ded` does. And a record of exports does not belong to a document: it
belongs to the project, the same document can be issued three times, and a record
must be able to OUTLIVE its document — which is exactly what makes `staleExports`
useful.

Migration: none. A project saved without `exports` reads as an empty list, which is
the truth for every project before this. Explicitly not to be done: inferring a
retroactive record from the existence of a document. That a document exists does not
prove it was exported.

`at` comes from the caller, never from the store's own clock — the rule
`detailing.svelte.ts` already states about itself.

And three things the UI must not then claim: that the file still exists on disk (the
browser cannot know), that "exported" means "issued for construction" (`issue-submit`
and its blockers are a different thing and must stay one), or that a stale export is
an error (exporting and then editing is a normal way to work).

── What each fixture reaches ──────────────────────────────────────

    rc-design-qa-8      0 conflicts ·    0 markers · modelled 9              · 5 s
    rc-qa-diagnostic   68 conflicts ·   68 markers · +provisional 5          · 4 s
    pro-edificio-7p  1318 conflicts · 1310 markers · +provisional 6 failed 6 · 22 s

The conflicts fixture is FASTER than the eight-element one.

Three states no fixture reaches, each with what it would take:

`refused` / unreinforced — zero in all three. Needs a member the design refuses to
reinforce; a section too small for its demand should do it without touching the
engine.

`doc-error` — all three coordinate. Needs detailing generated but NOT coordinated,
and `__stabileoActions.seedDetailing` already writes `model.detailing` directly,
which is probably the cheapest route and touches no production code.

`ConflictInspector` — renders from a marker click in the WebGL scene, which
raycasts. The fix is one line beside `selectAssembly` and `reviewAssembly` in
`e2e-hooks.ts`, and that file is shared surface and a mutation hook, so it is
proposed rather than added.

**H1-D is not closed**, and the handoff says so: the section cut, the narrow-viewport
rail where the toggle actually appears, per-family visualisation, and the three above
remain.
Markup and styles only. No behaviour.

    RebarWorkspace.svelte        598 → 530
    RebarWorkspaceHeader.svelte        138

── Why, and why now ───────────────────────────────────────────────

`RebarWorkspace` has sat within a dozen lines of the 600 ceiling for this whole
branch, and TWICE a comment alone pushed it over: once in the typography change and
once in the close-button relabel, the second of which went out red. A file that
cannot afford to be explained stops being explained, which is the failure the
ceiling exists to prevent rather than cause.

Done before adding behaviour rather than after, and in its own commit, so the
conflict-hook work lands on a file with room.

The precedent is `FloorFamilyStateCard`, extracted from `FloorFamiliesPanel` at 607
lines instead of the limit being raised.

── What did not move ──────────────────────────────────────────────

Every control calls back into the parent, `railOpen` is `$bindable` so the parent
still owns and persists it, and the four `data-testid`s are unchanged — the specs
that read them do not know this file exists. It takes what it needs and decides
nothing.

One line is NEW rather than carried: `font-family: inherit` on `.topbar button`. The
parent's `:global()` control rule is scoped to `.workspace`, and Svelte scopes a
selector to the component that declares it, so a button in the new file would have
fallen back to the UA font — the exact defect `92c061ec` fixed. Caught by reasoning
about the scoping rather than by a test, and the typography spec confirms it.

── A correction to an earlier reading of mine ─────────────────────

I reported that `rebar-back` does not exist. **It does** — behind
`{#if rebarWorkspace.canGoBack}`, which is `history.length > 0`, the SELECTION
history. It is "Previous", for stepping back through members you have looked at, and
it is absent on a freshly opened overlay because there is no history yet. The
measurement was right and the conclusion was wrong.

That also means the close-button relabel and this are two different things, which is
now written where both live: `Previous` walks the selection history, `Back to the
workflow` leaves.

── Regressions, before and after ──────────────────────────────────

unit 374 files / 7013 tests · typecheck 479 against baseline 479 · production build
clean · unused CSS 137, unchanged · `rc-design-gates` 29/29 ·
h1d-viewer-audit + viewer-typography + viewer-panel-tokens 31 ·
h1e-conflict-states + rebar-toggles 17 · rebar-3d 24. Seventy-two E2E across the
viewer, all green.
`ConflictInspector` renders from `selection.conflict`, set only by clicking a marker
in the WebGL scene — raycast against the canvas, at a screen position no test can
compute. It had never been exercised.

    liveRebarSceneConflictAt(slot)     rebar-scene.ts — read-only, test hooks only
    __stabileoActions.selectConflict   the mutator, beside selectAssembly/reviewAssembly

The accessor is exactly parallel to `liveRebarSceneCensus`, which exists for the same
reason: `e2e-hooks.ts` cannot reach `liveScene`, and the alternative is a test that
rebuilds the marker list and asserts against its own copy instead of against what is
drawn. Nothing in the application calls it.

It resolves the SLOT through the scene's own `conflictAt`, not an index into the
document's conflicts — the compaction moves markers between slots when a filter is
on, so asking for slot 0 gets whatever a click there would have hit.

── What it found on its first run ─────────────────────────────────

`Isolate` / `Clear isolation` in the inspector were an `{#if}/{:else}` pair: pressing
one DESTROYS the pressed button and creates the other, the focused element leaves the
DOM, and focus falls to `<body>`. The same defect, in the same shape, as the one
fixed in `SelectionDetails` — and it had been sitting behind a marker click that
nothing could exercise, which is the whole argument for the hook.

One node that swaps its label, action and testid. Measured: focus survives both ways.

── The inspector, now covered end to end ──────────────────────────

The not-constructible band as a sentence. Both bar ids, separately and different from
each other — "A/B are 14 mm apart" is a measurement, "bar c12-4 in column 12" is a
thing you can go and look at. Measured against required. The pair class. Severity
read as TEXT across six slots, because `.head.overlap strong` and the base head are
two levels and the colour is support. Centre and isolate both acting, neither losing
the keyboard. And Escape still leaving with a conflict selected, landing back on
`doc-3d`.

Gates: unit 374 files / 7013 tests · typecheck 479 against baseline 479 · production
build clean · unused CSS 137, unchanged · h1e-conflict-states 10/10 · 127.0.0.1:4003.
…gs already did

The application measured a lip two ways. `computeSectionProperties`'s `C-custom`
used the flange MID-LINE — `(h - tf)/2 - c/2` — while `createCShape` walks from
`-halfH` and `crossSectionPath`'s `'C'` from `-hh + lip`. With the same `c` the
calculation counted `2t²` more material than either drawing: 452 mm² against 444 on
a `C 100x50x15x2`, about 1.8 %.

Convention decided by M1 in `docs/handoffs/m2-lip-convention-proposal.md`, and
applied here because `section-shapes.ts` also holds the concrete templates, so it is
not M1's to change unilaterally. The patch is theirs, verbatim.

── Verified against the polygon, not against a table ──────────────

`cold-formed-lip-convention.test.ts` integrates the outline `createCShape` actually
walks — Green's theorem over its vertices — and compares A, Iy and Iz against what
`computeSectionProperties` returns for the same parameters, on the four sizes from
the proposal. They agree to **1e-9 relative** on the inertias and to 1e-12 absolute
on the area.

A table of expected numbers would pin four sections. This states the property: the
two halves describe the same object. Confirmed to be a real test by reverting the
convention and re-running — it fails by exactly 8, 12.5, 18 and 4.5 mm², which is
`2t²` for t = 2, 2.5, 3 and 1.5 mm. M1's table reproduced independently.

── The regime that was worse than a t/2 shift ─────────────────────

`createCShape` renders an UNLIPPED channel when `lip <= tf`, and the calculation
added `2·c·tl` of lip regardless. For `c <= tf` the app computed a section with a lip
and drew one without — material that existed in the numbers and not in the outline.

`Math.max(0, c - tf)` closes it by construction, with no new guard: the useful lip is
≤ 0 exactly when the drawing refuses to draw one. Tested three ways — `c === tf`
matching the drawn outline, `c < tf` computing rather than being rejected, and the
result equalling a `U-custom` of the same plate, which is the strongest way to say
"no lip means it IS the channel".

The validity bound loosens with it: lips collide at `c > h/2`, not `c + tf > h/2`.
The second sub-decision the patch exposes, taken on purpose and tested at 0.049,
0.050 and 0.0501 on a 0.100 section.

── Z: not in this tree, and it matters ────────────────────────────

The brief says C and Z must move together. **In H1's tree there is no Z at all** —
no calculation in `section-shapes.ts` and no drawing case; the `'Z'` occurrences in
`section-drawing.ts` are the SVG closepath command, which is what my first grep
found and misread.

Z lives in M1's `lib/profiles/cold-formed.ts` (`partsC` / `partsZ`), added in
`01da50cb` and `8f80481e` and NOT merged here. Its mirror is two lines and is spelled
out in §4 of the proposal:

    vLip = (h - c - t) / 2        and   ht: c - t   on both lip parts

So until those branches integrate, **H1's C follows the outer face and M1's C and Z
still follow the mid-line.** That is a real inconsistency across the two branches and
not something this commit can close from here. `validateColdFormed`'s `lipsCollide`
has to follow the loosened bound in the same integration.

Reported to M2 for validation.

Gates: unit 375 files / 7022 tests — nothing depended on the old values · typecheck
479 against baseline 479 · production build clean · the nine new assertions verified
to fail on the old convention.
…d the families

Two fixtures asked for. One turned out not to need a fixture; the other cannot be
built with the hooks that exist, and both are reported as measured rather than
worked around.

── `doc-error` is not reachable, structurally ─────────────────────

Measured, not argued. After `seedDetailing([])`:

    before   documents-stage 1 · doc-xlsx 1
    after    documents-stage 0 · documents-empty 1 · doc-xlsx 0 · doc-error 0

`buildDocument` returns null on exactly one condition — `persisted.assemblies.length
=== 0` — and `DocumentsSection` renders its whole stage behind `{#if !selected}`,
where `selected` derives from the same list. The emptiness that would make the build
fail also removes the buttons that would call it.

So `detailing.doc.noCoordinated` is defensive code for a race the codebase already
designed away — `buildDocument`'s own comment describes the fix. Asserted as
unreachable with the mechanism, and NOT manufactured: forcing it would mean changing
production to make a guard fire.

── `refused` is blocked on one missing hook ───────────────────────

`REFUSED` comes from a DESIGN outcome — `SECTION_INADEQUATE` or `SEARCH_EXHAUSTED`
at `element-status.ts:345` — not from a seeded detailing. `seedDetailing` writes
`model.detailing`; the outcomes live in `verificationStore`.

Measured across all three fixtures: `VERIFIED` 8 / `VERIFIED` 22 + `PROVISIONAL_BIAXIAL`
8 / `VERIFIED` 198 + `PROVISIONAL_BIAXIAL` 10. **None refuses.** And there is no UI
route: `ProSectionsTab` carries not one `data-testid`, nor does `SectionChanger`, and
`BatchEditDialog` edits reinforcement rather than sections.

What is missing is one mutator, exactly like `selectConflict`:
`updateSection(id, data)`. Then the fixture is real rather than fabricated — shrink a
beam until its section does not suffice, `designAll()`, and the engine refuses on its
own. Not added: the block authorised existing hooks, and this is not one.

── Three absences that are three different sentences ──────────────

`documents-empty` (nothing to document, and NO export offered) · `doc-none`
(detailing exists, no document — and the exports ARE offered, because the first one
builds it) · `rebar-empty-families` (these families have nothing, which is not the
same as the workspace being empty).

Asserted in all three languages that the first two do not share a sentence. That is
the floor-families defect exactly: "we looked and found none" printed identically to
"nobody looked".

And the VERIFIED rule where it is easiest to break: an empty stage says none of
`verified` / `issued` / `constructible`, and the elements that would carry a verdict
do not exist at all. Scoped to the empty stage after a first version read the whole
panel and failed on "not verified" in the census, a verification chip, and a true
sentence in the regulations panel — the rule is that an absence must not claim a
verdict, not that the word may not appear on screen.

── The three H1-E items, and three instruments of mine that were wrong ──

**The section cut is a CLIPPING PLANE**, not a filter: `localClippingEnabled = true`,
no mesh removed, census unmoved. My first version asserted the census and failed on
all three axes — the same mistake as reading the census for an opacity change. What
the DOM can see is the dependent control, and the bounds come from the scene (~5.4 m
in y) rather than a fixed 0..1.

**`type="range"` rejects an off-step value** — "Malformed value" — so the position is
set through the DOM and dispatched, not filled.

**The rail is opened AT 820, not resized into it.** `onResize` sets
`railOpen = wide` when the width crosses 860, deliberately: "The rail is one tap away
and starts closed." My first version raced that handler. At 820 the toggle appears —
0×0 at 1280 — collapses the rail and restores it, moves `aria-expanded` (its only
accessible payload; it is a glyph with no label) and keeps focus. The canvas keeps
more than 400 px, which is the reason the rail becomes a sheet at all.

**The tally splits families into solids / longitudinal / transverse**, so asserting
the census's bar count against its cells was reading one number expecting another.
Per-family rows now, plus the headline total against the census — which is the drift
that matters.

Families: every family with geometry toggles off and back independently, and the five
empty ones are NAMED rather than hidden, four of five appearing by name.

Gates: unit 375 files / 7022 tests · typecheck 479 against baseline 479 · production
build clean · unused CSS 137 · h1e-rail-and-section 9/9 · h1e-absence-states +
h1e-conflict-states 22/22 · 127.0.0.1:4003.
Evidence only. `120f15cc` is unchanged and the convention was not revisited.

── A, Iy and Iz against the polygon ───────────────────────────────

The test integrates the contour `createCShape` actually walks and compares it against
`computeSectionProperties`. They agree to 1e-12 absolute in area and 1e-9 relative in
both inertias, on all four sizes — the same numbers as §3.1 of the proposal, arrived
at independently rather than copied.

Verified in BOTH directions: reverting to the mid-line makes it fail by 8.0, 12.5,
18.0 and 4.5 mm², which is exactly `2t²` for t = 2, 2.5, 3 and 1.5.

── One thing the proposal did not tabulate ────────────────────────

**Iz falls far more than Iy** — 4.7 % to 5.6 % against about 2 %. The lip sits at the
flange tip, far from the centroid in z and near it in y, so its lever arm weighs much
more on Iz. For a cold-formed section bent about its weak axis the change is of order
5 %, not 2 %. Worth flagging because "about 1.8 %" is the figure the discussion has
been carrying, and it is the area figure.

All four fall and none changes sign or order: the outer face counts less material,
never more.

── The two sub-decisions, exercised ───────────────────────────────

`c <= tf` is a plain channel, closed by construction rather than by a new guard —
asserted three ways, including that the result EQUALS a `U-custom` of the same plate,
which is the strongest way to say "no lip means it is the channel".

The bound loosens from `c + tf > h/2` to `c > h/2`, tested at 49 / 50 / 50.1 mm on a
100 mm section. `validateColdFormed`'s `lipsCollide` has to follow it or the validator
will reject sections the calculation accepts and computes correctly.

── Templates and saved models ─────────────────────────────────────

No concrete template touched — the change is inside one `case`, and the full suite of
375 files / 7022 tests passes, so nothing depended on the old C values.

Saved models are not recomputed: `snapshot`/`restore` stores A and I rather than
rebuilding them from `built.params`. Which means **one project can hold two C sections
with the same designation and different areas** if one predates this. Not a defect of
this change — it follows from properties being persisted — but better written down
than discovered.

── Z is not in this tree ──────────────────────────────────────────

No calculation and no drawing case; the `'Z'` matches in `section-drawing.ts` are the
SVG closepath command, which is what my first grep found and misread. Z lives in M1's
unmerged `lib/profiles/cold-formed.ts`.

So H1's C follows the outer face while M1's C **and** Z still follow the mid-line:
different areas for the same designation until they integrate. If H1 merges without
the two-line mirror, the two files will disagree inside one tree.
…ed as

`updateSection` joins `selectConflict`, `selectAssembly` and `reviewAssembly` as a
test mutator in `e2e-hooks.ts`. Production is untouched.

── The fixture produces the refusal, it does not write it ─────────

    rc-design-qa-8, section 2 (`RC Col 400×400`) → 90 × 120 mm
    → SEARCH_EXHAUSTED ×8 · VERIFIED ×4

The engine enumerates the whole code-permitted envelope for a column that cannot
carry its demand, finds nothing that verifies, and says so. No state is written
anywhere.

Two things that cost time and are recorded so they cost nobody else any: **section 2,
not section 1** — id 1 is unused by the designed members and shrinking it changes
nothing — and **90 × 120 mm, not 50 × 60** — shrinking all eight sections took the
design past a ten-minute budget, because the search enumerates a far larger envelope
when nothing fits at all.

── What it uncovered: REFUSED is shadowed by FAILED ───────────────

The outcome IS `SEARCH_EXHAUSTED`, and `element-status.ts:345` maps that to
`REFUSED`. **The rail never shows it.**

Line 316 tests `verificationStatus === 'fail'` FIRST, and a member whose design was
refused also fails verification — the refusal happened precisely because nothing in
the envelope verified. So `FAILED` wins and the `REFUSED` branch is unreachable.

    rail: failed 5 · refused 0 · modelled 5

`FAILED` being first is right for the case its own comment describes — a member
carrying a stale VERIFIED outcome that fails now. But it swallows the distinction the
states exist to make, and the same file's header spells out the remedies: a refusal
means "change the section, or design by hand", a failure means the reinforcement does
not pass. Reporting the second sends the reader to the wrong fix.

**Not patched here.** Reordering a classifier changes what every member in the
application is called, and that is a decision rather than a fix in passing. The test
pins it AS IT IS — `failed > 0` and `refused === 0`, with the reason — so the day it
is reordered the test fails and gets updated deliberately.

── And what the refusal does show ─────────────────────────────────

The column loses its steel and keeps its concrete: column bars 200 → **0**, solids
still 4. `.unreinforced` appears and explains itself, in the workspace — H1-D had
asserted it absent with the premise `refused === 0`, and this is the other side of
that premise, which is why it was written as one rather than as an annotation. No
refused row reads `verified` or `certified`.

── `doc-error`: the chain, written out ────────────────────────────

Confirmed again, and the whole chain recorded so it is not re-attempted:
`buildDocument` returns null only on `assemblies.length === 0`; `DocumentsSection`
renders its entire stage behind `{#if !selected}`, derived from that same list; with
zero assemblies the `documents-empty` branch renders instead; the four export buttons
live inside the other branch and stop existing; and `currentDoc()` is called by
nothing else.

**Step four removes the buttons before step five can happen**, and no ordering leaves
`selected` non-null with the list empty — it is the same list. The unreachability test
stays.

Gates: unit 375 files / 7022 tests · typecheck 479 against baseline 479 · production
build clean · unused CSS 137 · h1e-refused-state 5/5 · 127.0.0.1:4003.
…rcement failure

`FAILED` was tested unconditionally and won every time: a member whose design was
refused also fails verification, because the refusal happened precisely when nothing
in the code-permitted envelope verified. So `REFUSED` was unreachable — measured on a
starved column, `SEARCH_EXHAUSTED` ×8 in the design table against `failed 5 ·
refused 0` in the rail.

The two mean different remedies, which is why they are two states. This module's own
header: a refusal means "change the section, or design by hand". Reporting it as a
failure sent the reader to change reinforcement that no reinforcement can fix.

`FAILED` keeps priority for exactly the case its comment describes — a `VERIFIED`
outcome that fails now, or no outcome at all, where nothing else would name it.

── Tests first, and they failed first ─────────────────────────────

Written before the change and run against the old order: the three refusal cases
failed with `expected 'FAILED' to be 'REFUSED'`, the five regression cases passed.
Then the change, and all eight pass.

── An error of mine that the existing tests caught ────────────────

The first version said `summary.outcome !== 'VERIFIED'` — "anything but verified" —
and that broke the narrow biaxial exception: a `PROVISIONAL_BIAXIAL` proposal that
ALSO fails on flexure fell back to `PROVISIONAL` instead of `FAILED`.

Two pre-existing tests caught it, in `element-status.test.ts` and
`provisional-presentation.test.ts`. That is the argument for the explicit allowlist
that replaced it: `PROVISIONAL_BIAXIAL` does not explain a flexure failure, and
`DEMAND_UNAVAILABLE` does not explain a verification failure either.

── What changed, and what did not ─────────────────────────────────

    SEARCH_EXHAUSTED + fail    FAILED → REFUSED
    SECTION_INADEQUATE + fail  FAILED → REFUSED
    UNSUPPORTED + fail         FAILED → UNSUPPORTED
    VERIFIED + fail            FAILED, unchanged
    no outcome + fail          FAILED, unchanged
    PROVISIONAL_BIAXIAL        unchanged, both branches

Both sides stay in `NOT_FOR_CONSTRUCTION_STATUSES` — the one list the viewport
legend, the sheets, the schedule and the report all consume — so no path here can
make a refusal read as finished work. Asserted across the change rather than at one
call site.

── Consumers, checked before editing ──────────────────────────────

`RebarStatusPanel` already had both dots, both mirroring the scene: `.st-failed`
`#e0444a`, `.st-refused` `#d4762a`. No CSS changed. `ELEMENT_STATUS_ORDER` and
`NOT_FOR_CONSTRUCTION_STATUSES` already held both. The other `'FAILED'` strings in
the tree are different enums — a family certificate status and a CAD handoff field —
and are untouched.

Both states were already translated in all three offered locales: Refused /
Rechazado / Recusado, Failed / Falla / Falha. No new key, and the i18n gates stay
green.

── The fixture now asserts the corrected behaviour ────────────────

`h1e-refused-state` went from pinning `refused === 0` with the reason to asserting
the rail SAYS Refused, in words, with the scene's unreinforced orange rather than the
conflicted red.

Gates: unit 375 files / 7029 tests · typecheck 479 against baseline 479 · production
build clean · unused CSS 137 · rc-design-gates + provisional-presentation 44/44 ·
h1e-refused-state 5/5 · h1e-conflict-states + h1e-absence-states + h1d-viewer-audit
31/31 · 127.0.0.1:4003.
Batuis added 4 commits August 22, 2026 18:17
Two handoffs, no code. H1 stays in closing mode.

── Exports: a correction to my own earlier report ─────────────────

I wrote at the close of H1-C that "the file contents are unverified". That is true of
the BROWSER path and false of the renderers: `renderSchedule`, `renderDrawings` and
`renderReportHtml` are exercised by **thirteen** unit test files. The document draws
the line where it actually falls.

**XLSX** — the download happens, the filename carries the revision, and the export is
what builds the document. Nobody opens the `.xlsx`: `exportToExcel` takes rows and the
conversion to a workbook is never read back. What goes in is checked; what comes out
is not. Nor is `onlyExtras: true` doing what it promises.

**DXF** — asserted in unit as containing `SECTION`, `ENTITIES`, `EOF`, `ARC`, and the
not-for-construction stamp. The download has never run in a browser — not even a
filename check — and nobody parses the result. There IS a parser in the tree,
`parseCadDxf`, used for IMPORT; exporting and re-parsing would close the loop with
code that already exists. R12/`AC1009` is claimed by the generator and asserted
nowhere.

**Report** — `window.open` + `print()`, so there is no PDF to inspect at all: `print()`
hands off to the OS. The popup opening is measured; its DOM is not. And the `.html`
fallback for a blocked popup — the only branch that produces a file — has never run.

Also corrected here: I called `doc-report` "a silent no-op". It is not. My probe waited
for a download from an action that opens a window.

── `ExportRecord` ─────────────────────────────────────────────────

Nothing is recorded. Someone who exported the DXF, edited a footing and came back
cannot know the file no longer matches — the model knows it was superseded and nothing
connects that to the files that left.

Proposed as SEPARATE state, not a field on `DocumentModel`, and that is the decision
the cost turns on: the model is serialised and read by three renderers, a record
belongs to the project rather than to a document, and it must be able to OUTLIVE its
document — which is what makes `staleExports` useful at all.

Migration: none. Absent means an empty list, which is the truth for every earlier
project. **Explicitly forbidden: inventing a retroactive record.** That a document
exists does not prove it was exported, and deriving one would produce a list of
issues that never happened, on the one surface whose entire purpose is to say what
actually went out.

── What the browser cannot claim ──────────────────────────────────

That the file still exists on disk, that the user saved it, that the PDF printed, or
that it has not been modified. Hence *export* and not *delivery* — and hence a stale
export is information, not an error: exporting and then editing is a normal way to
work. And "exported" is not "issued for construction"; `issue-submit` and its
blockers stay the only thing that says the second.

── C/Z: an integration problem, not H1 work ───────────────────────

H1 applied its half in `120f15cc` and will not touch the convention again. The mirror
— two lines in `partsC`/`partsZ` — is M1's, in a file that does not exist in this tree,
and M2 verifies it there.

The part worth the document: **nothing will fail at merge time.** They are different
files, so there is no conflict to warn anyone, and whichever branch lands first
without the mirror leaves two section modules disagreeing INSIDE one tree — which is
worse than the current disagreement between branches, because it stops being obvious.

Plus the third item that is easy to miss and is not about drawing:
`validateColdFormed`'s `lipsCollide` has to follow the loosened `c > h/2`, or the
validator will reject sections the calculation accepts and computes correctly.

Gates: no code changed. unit 375 files / 7029 tests · typecheck 479 against baseline
479 · production build clean · unused CSS 137.
…references

No regression: typecheck 479 against baseline 479, unit 375 files / 7029 tests,
production build clean, unused CSS 137. Nothing functional changed.

What was wrong with the documents themselves:

── A section that had gone false ──────────────────────────────────

`h1e-fixture-coverage.md` §4 still read "**H1-D: no cerrado**" and listed five open
items. All five had since been closed — the section cut, the narrow rail and
per-family display in §6, the real refusal and the conflict inspector in §7, the
classification order in §8 — by later sections of the same file.

Marked as superseded rather than deleted, with each item pointing at the section that
closed it, so the sequence still reads. One thing on that list IS still open and is
now said plainly: no RC fixture produces an unreinforced member on its own; the one
that exists is produced by `updateSection`.

── Two documents proposing the same contract ──────────────────────

`h1-export-record-contract.md` (140 lines) and `h1-export-coverage-and-contract.md`
(224). The second is strictly fuller. Two live proposals for one contract is how a
thing gets half-implemented from the wrong one, so the older is removed and the one
section it had that the newer lacked — scope and ownership: fourteen components read
the detailing store, `DocumentsSection` is concrete-owned — is folded in as §9.

The single reference to the removed file, in `h1e-fixture-coverage.md`, now points at
the surviving one.

── Two references to a file that is not in this tree ──────────────

Both C/Z documents cite `m2-lip-convention-proposal.md` as the source of the
convention, and it lives in `feat/pro-steel-m1` at `f936f29c`, not here. A reader in
this tree would not find it. Footnoted with the command that reads it.

The other two dangling references in `docs/handoffs/` predate this branch and are left
alone.

── Verified rather than assumed ───────────────────────────────────

The i18n handoff's headline figure re-counted against the current dictionaries: `pt`
is still missing exactly **1172** keys that `en` and `es` both have, with the same
distribution. The eight keys H1 added did not move it, because they went into all
three.

`WorkflowStages.svelte` and `DesignOverview.svelte`: still **zero** H1 commits, so the
shared-chrome proposal describes files that are genuinely untouched. Its exemptions in
`concrete-copy-contrast.spec.ts` and the `test.fail()` on the chevron are both still
in place, which is what makes that proposal actionable rather than historical.

The branch is left waiting for integration and QA.
… sweep

`__probeM`, `__probeN` and `__probeO` reached the branch in `97bdc797` through a
`git add -A`. I had been deleting each diagnostic after reading it; these three
survived because that commit staged everything.

They are not tests. No assertions, `console.log` for output, and `__probeM` carries a
600-second timeout — it is the one that hung a ten-minute shell while I was hunting
for the section that would refuse. `playwright.config.ts` has no `testIgnore`, so a
full E2E sweep would have picked all three up: slow, noisy, and impossible to read as
pass or fail.

Found while listing what H1 added to `e2e/` in order to write the manual QA guide,
which is the sort of thing that only surfaces when you go looking at the branch as a
whole rather than at the diff in front of you.

A `.gitignore` rule for `web/e2e/__probe*.spec.ts` so the next one cannot repeat it.
`web/e2e/__screenshots__/` stays committed — that exception is already documented
two lines above and is untouched.

Gates: unit 375 files / 7029 tests · typecheck 479 against baseline 479 · production
build clean · unused CSS 137 · the Playwright test list no longer contains a probe.
H1 is stopped. This is the handover to a human tester, and it is written around what
683 E2E tests and 7029 unit tests cannot judge: whether the screen reads well,
whether the order makes sense, and whether an engineer understands what they are
being told.

── What it leads with ─────────────────────────────────────────────

The three models by what they PRODUCE, because that is what decides a route:
`rc-design-qa-8` verifies everything, `rc-qa-diagnostic` gives 68 conflicts and both
banners in about four seconds, `pro-edificio-7p` is the only one with failed members
and costs twenty.

Then the five surfaces in the order a user meets them, with the specific thing to
look at in each — the dash that is not a zero, the sheet group that should not look
like a native fieldset, the exports that are enabled before a document exists, the
viewer's typeface, and whether isolating with the keyboard loses focus.

── A do-not-report list ───────────────────────────────────────────

Seven decisions already taken and documented: the dangling chevron (shared file,
proposal written), `Escape` not closing a `<details>` (standard; the overlay closes
because it IS modal), the exports enabled with no document (the first export is what
builds it), no rail toggle at 1280, the sheet preview living in Detailing, the
missing export record, and English text in the eleven locales that are deliberately
incomplete.

Filing those again costs everyone time, and a QA guide that does not say so invites
it.

── And the six things no test covers, ranked ──────────────────────

The XLSX and the DXF are verified by filename and never opened; the report's window
is confirmed to open and its HTML is unread; the `.html` fallback for a blocked popup
has never run; `pt` is missing 1172 keys outside the concrete surface; long real
content is untried; and nobody has orbited the 7-storey building for a while with
filters chained.

Ranked by where something is most likely to be found rather than listed
alphabetically.

── The distinction the guide closes on ────────────────────────────

"It fits on screen" is not "it is understood". The documents stage fits perfectly at
all six measured combinations and still does not tell you what you exported. That
kind of finding is the one a human is for.

M2 has not reached closure: `feat/pro-steel-m1` is still at `f936f29c` and its
`cold-formed.ts` does not carry the lip mirror yet. Reported as fact, not chased.
Batuis added 3 commits August 26, 2026 20:53
Two conflicts, both in the status-badge CSS, and both cases where the two branches
were fixing the SAME defect: `--st-accent` — the brand vermillion this application
paints actions with — used as a status. Neither side is discarded; the base's intent
is contained in H1's resolution in both.

OutcomeBadge.svelte, two hunks:

  `.badge-fail` — the branches agree. Both moved the label and border off `--st-accent`
  and onto `--st-danger`. H1's version keeps that and carries the FILL onto
  `--st-danger-bg` as well, because `concrete-design-raw-colours.test.ts` is a red gate
  on this surface. The base's change IS this change, one token further along.

  `.badge-outcome-SECTION_INADEQUATE` — the one hunk where they genuinely differ. The
  base put the label on `--st-warn`, which removes the accent and leaves warn-on-warn.
  H1 measured that pairing: `--st-danger` as a label on `--st-warn-bg` is 4.09 over
  `--st-surface-3`, failing AA on the darkest ground the chip sits on, which rule 1 of
  `shared-status-tokens.test.ts` forbids. The resolution keeps the base's intent — the
  accent is gone — and answers the gate: severity on the border at 3:1, words at full
  contrast, still distinguishable from `.badge-warn` and `.badge-fail`.

DesignToolbar.svelte, one hunk: the base deleted `.counts`, `.count`, `.count-sep` and
the nine `.c-*` rules. Checked against this file's own markup, that is right — H1 had
moved the counts to `DesignOverview.svelte` and left the rules behind styling nothing.
H1's own correction there (`.c-fail` and `.c-sect` onto `--st-danger`) is not lost: it
lives on the surface that renders them, where `.tone-bad` is already `--st-danger`.
`.c-prov` is the deletion `run-summary-reported.test.ts` explicitly asks for — its note
reads ".c-prov in the toolbar kept this green while styling nothing". `.detailing-auto`
is NOT deleted: the markup still renders it, and it is now defined once, in the fuller
form that carries the `:focus-within` outline.

Three consequences the conflicts did not show, found by running the gates:

  `--st-accent` on `.progress-fill` arrives from the base, which restored a fill the
  merge base had left at `background: none` — an invisible progress bar. It is a real
  fix and it is kept. `shared-status-tokens.test.ts` requires every accent use to state
  its meaning, so it is declared: an action in progress, a decorative fill inside a
  track with the count written beside it, nothing read off the colour.

  The provisional chip arrives from the base as the literal `#a066d3`, with a comment
  explaining that a `var()` would break its agreement with `three/rebar-scene.ts`. That
  was true when written and is not true here: H1 created `--st-provisional` for exactly
  this, and rule 3 of `shared-status-tokens.test.ts` holds it equal to what Three.js
  paints, compared as a resolved colour. So the token, not the literal — the same
  conclusion H1 already reached in `OutcomeBadge`. `run-summary-reported.test.ts` now
  asserts the token; its claim is unchanged and its guarantee is stronger.

  `chs-shear-agreement.test.ts` is new from the base and covers `fix(engine): a circular
  tube's shear was half its true peak`. It failed here against a WASM binary older than
  that fix, not against the merge; rebuilt from the merged Rust, it passes. No Rust,
  Cargo or WASM source was touched.

Verified on the merge: typecheck 0 (479 errors, baseline 479, none new), build 0,
`npm run test` 0 — 7153 passed, 12 skipped, 1 todo across 384 files. E2E green:
pro-panel-consistency, pro-panel-structure, pro-design-gates, concrete-copy-contrast,
status-token-consumers, h1a-audit, h1b-panel-navigation, h1c-documents-flow,
h1d-viewer-audit, h1e-refused-state, h1e-absence-states, h1e-conflict-states,
h1e-rail-and-section. The one expected `test.fail()` in h1b — the shared
`WorkflowStages` chevron — is still expected and still failing, as it should be.
The manual QA guide was written before `feat/pro-steel-family` advanced 44 commits
under this branch, and the merge brought two changes a tester will see and would
otherwise report as defects.

The run's progress bar was invisible on H1 — the merge base left `.progress-fill` at
`background: none` — and the base restored the fill. And the proposal chip in the design
summary moved off the amber of a warning and onto the violet every other surface paints a
proposal with, which is a semantic correction rather than a palette preference.

Both are listed with what they were, what they are, and the one thing about them that IS
worth reporting: the chip's violet and the 3-D viewer's are held equal by a test that
compares resolved colours, so a visible mismatch between them is a real finding.
…isabled

D7, F6, F10 and FD-E each filled a field, pressed `review-submit` and asserted a
`review-error` came back. That journey stopped existing in `3f2f409c`, which disabled
the control whenever `reviewBlockers` is non-empty and moved the reasons next to it.
The click could never land, so each test spent its full 60 s timeout retrying it and
then failed pointing at the click — no cause, four minutes of a suite, and nothing
learned.

The production change was right and these tests were left behind it. `h1-manual-qa.md`
already states the contract they should have been measuring: "the button is disabled
until you put your name in and accept the provisional calculations, and the reasons are
written beside it. If the button is grey with no explanation, THAT is a bug."

So each now reads `review-blockers`, and the assertions are unchanged in substance:

  D7  — unnamed, the control refuses in advance and the reasons are WRITTEN, not
        implied; naming the engineer changes what is missing.
  F6  — a COORDINATED floor blocks the review, and the blocker names that state.
  F10 — provisional work refuses in English and names WHICH assembly; acknowledging it
        ENABLES the control, which is then clicked and records the review. The gate is
        asserted in both directions, not merely as a grey button.
  FD-E— with the acknowledgements ticked, the engineer named and the notes filled, the
        gate is still unwalkable, names CONSTRUCTIBLE, and records nothing. It also
        asserts the refusal is about the STATE and not about the name already supplied.

Nothing in production changed and no semantics moved: this is the same gate, read where
it now speaks. Each of the four dropped from a 60 s timeout to under four seconds.

Verified: the four tests, then all three files whole (39 passed), then the H1 E2E set
(97 passed; the one expected `test.fail()` in h1b — the shared `WorkflowStages` chevron
— is still expected and still failing). typecheck 0 against baseline 479, build 0,
`npm run test` 0 with 7153 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant