Skip to content

M1 — Metallic PRO: material and section selectors, section properties with a declared basis, and the shed's longitudinal load path - #156

Open
Batuis wants to merge 37 commits into
mainfrom
feat/pro-steel-m1
Open

M1 — Metallic PRO: material and section selectors, section properties with a declared basis, and the shed's longitudinal load path#156
Batuis wants to merge 37 commits into
mainfrom
feat/pro-steel-m1

Conversation

@Batuis

@Batuis Batuis commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Ready for review — read this box first

Merge order: this PR (#156) merges into main FIRST. #164 merges second.
#164 is based on feat/pro-steel-m1 and contains this branch by merge, so it is not
independent: merging it first, or retargeting it at main, would carry this branch's entire diff
in under #164's review.

Scope of this PR — the metallic PRO foundation: material and section selectors on a
GradeSource seam, section properties with a declared basis on every number, the shed's
longitudinal load path, and the grade/profile pairing.

No new functionality was added while preparing this for review. The only changes since the
feature work are three test fixes and documentation. Nothing metallic is presented as verified.

Jump to: Delivered scope · Known limitations ·
Non-blocking findings, for M3 ·
Nothing classified as blocking

Continues PR21 (#135) from its final SHA 08917b9f. Draft: backup and review surface, not authorisation to merge.

Full account: docs/handoffs/m1-steel-selectors-and-bracing.md. Scope split for what comes next: docs/handoffs/m1-m2-scope-split.md.

What this branch does

  • The declared grade reaches the metallic surface. PR21 left every lookupGrade call site passing undefined with a comment saying the catalogue was not on the branch. It is. Aluminium separates from steel for the first time, and because that drops rows from a list whose admission rule is isSteel, the inventory now names the non-ferrous members instead of losing them silently.

  • The angle family says which of its two standards each row came from. Eleven IRAM-IAS profiles were filed under EN 10056-1; provenance is read from the source array, never parsed from a name.

  • Section properties with a basis on every number. Radii of gyration are exact arithmetic; section moduli need a centroid the tables do not publish, so c comes from the verified canonical outline and is refused where nothing supports it — half the width of a UPN 200 would overstate Wz by 46%. Torsion is tabulated or absent, never derived. The root radius turned out to mean four different things.

  • A PRO grade picker on a GradeSource seam, stating the authority behind each value: product standard, the 45 grades marked typical of the alloy, the derived shear modulus, and thickness bands always shown with the design code that tabulates them.

  • A profile picker filtering by publishing body, design code and depth, with the full property card and a pinned comparison that survives a filter change.

  • The shed gets a longitudinal load path. Under a load along the building the default shed returned 2.4·10^11 m — not singular, so every isFinite check passed. Restraining translation above the eaves takes the response to exactly zero: the roof slides, because a planar truss with a pin-jointed web has no out-of-plane stiffness. Three members make the path and none makes it alone; the full system reaches 4.4 mm. purlins: false is answered in both halves.

  • Generated models carry the chosen grade, so generator.assume.placeholderGrade disappears when it stops being true.

  • The regulation line names its edition and the maturity of the support, both already on the binding.

  • Five WCAG AA failures found by measuring, three concrete (fixed by H1) and two in this branch's own joints panel: --st-accent and --st-ok on their own tints, at 3.55 and 3.75.

    Corrected after review — those two were never on screen. 851fd57b repainted .conn-ratio-badge, and a later census showed no template had applied that class since b71432cd, this branch's own sub-section split, which had moved the ratio to StageSection's neutral badge prop and deleted both spans. So the contrast fix was applied to CSS the browser does not paint, and the rules are now removed instead (6448e89d). The measurement stands for the three concrete failures; the two metallic ones were real defects in dead code. Full account in docs/handoffs/m1-m2-ci-audit-and-three-decisions.md §31, including why nothing flagged it: Svelte stops reporting unused selectors once a component has a class attribute it cannot read statically, which affects 30 of the repository's 169 styled components.

Constraints

No Rust, Cargo, WASM, solver or global-analysis change. Concrete baseline digest unchanged at 1bd4d9c1d575b085. Nothing metallic is presented as verified; the four states are intact and steelCountsAsVerified still returns the literal false.

No high-risk shared file was editedtokens.css, ProPanel, ProRibbon, StageSection, DesignOverview, OutcomeBadge, DesignToolbar, model.svelte.ts, member-context.ts, design-run.svelte.ts, DocumentModel, the main i18n dictionaries and the global PRO tests. Every new string lives in locales/steel/{es,en,pt}. The one exception is a single value replacement of conn.gap.aluminium.scope, applied by key name after H1 published its i18n commit, because M1's own change had made that sentence false.

Coordination items are documented rather than acted on: m1-h1-coordination.md, m1-grade-family-contract.md, m1-token-proposal-reconciliation.md, and the interface Section field proposed in the scope split.

Gates

typecheck 479/479 baseline (no new) · unit 7092 pass (re-run at 6448e89d: 7401 pass, 12 skipped, 1 todo) · build project 14 · npm run build ✓ · check:gate ✓ · E2E_PORT smoke 284 pass · 57 M1-specific e2e across four specs. E2E_PORT always explicit: 4173 was occupied by another worktree during this work.


Sync with main, and one thing it surfaced

origin/main at b579de87 is merged in with an explicit merge commit — no rebase, no force-push, no conflicts. It brought the README rewrite, the production favicon, the dev-asset gate and arc_length.rs; none of it is this branch's work and none of it was touched.

Running the complete local E2E suite rather than the smoke subset surfaced a spec that had been broken from the start and had never run anywhere.

basic-selection-permutations.spec.ts reads the state of the selection-kind toggles from aria-checked. SelectionPanel renders plain <button aria-pressed> and not radios-that-become-checkboxes — a deliberate choice the component documents, because a radiogroup owes a roving tabindex and arrow-key movement the list never implemented. On a button, aria-checked is always null, so the two reads in armKinds inverted each other: the additions loop clicked a kind on, and the removals loop, seeing null again on that same kind, clicked it straight back off. The fifteen-permutation audit failed on the first two-kind subset, in both viewports, reporting ["elements"] where it wanted ["elements","nodes"].

It never showed up because this file carries no @smoke tag and CI's e2e job runs npx playwright test --grep @smoke. The audit now genuinely walks all fifteen subsets — 5.5 s and 7.1 s against the 1.3 s and 3.8 s it took to bail out on the third one. The two equivalent reads in basic-selection.spec.ts were passing by luck of ordering and move to aria-pressed as well.

No product code changed and no assertion was weakened.

Gates, re-run on the merged head

typecheck 479 = baseline 479, no new errors · unit 7400 pass / 0 fail (398 files, 12 skipped, 1 todo) · build project 19 pass (14 plus the five main brought) · npm run build ✓ · full local E2E 638 pass.

Four local E2E failures, none of them this branch's and none of them fixable without breaking a stated constraint:

Failure What it is
basic-selection-permutations ×2 fixed — the aria-pressed bug above
rc-design-visual › @slow visual baselines (non-blocking) a darwin screenshot baseline one pixel wider than the committed one (697 px vs 696), 3 % of pixels over a 2 % tolerance. Fixing it means updating a snapshot. CI runs this job only on main or under the run-e2e label.
viewport-perf › @perf › la-bombonera a 60 s test timeout during a keyboard-orbit measurement while the rest of the suite shared the machine. Passes in isolation, 4/4 in 2.4 min. Fixing it means inflating a timeout. CI never runs @perf at all.
basic-demos › drawing a beam flaky, passed on retry #1 — a retry the spec itself declares, with its reasons, for landing synthetic clicks on a canvas whose layout moves between steps.

No snapshot was updated and no timeout was inflated.

Three test fixes, all found by running the gates rather than by reading

None is product code, none weakens an assertion, none is a snapshot update and none raises a timeout.

1 · basic-selection-permutations.spec.ts — the audit read an attribute the panel does not publish. It read aria-checked off the selection-kind toggles; SelectionPanel renders plain <button aria-pressed>, deliberately, because a radiogroup owes a roving tabindex the list never implemented. On a button aria-checked is always null, so the two reads in armKinds inverted each other: the additions loop armed a kind and the removals loop, seeing null on that same kind, disarmed it. The fifteen-permutation audit failed on the first two-kind subset in both viewports and had never run anywhere — the file carries no @smoke tag and CI's e2e job runs --grep @smoke. It now genuinely walks all fifteen subsets: 5.5 s and 7.1 s, against the 1.3 s and 3.8 s it took to bail out on the third. The two equivalent reads in basic-selection.spec.ts were passing by luck of ordering and move too.

2 · shed-bracing.test.ts — one shed solve per test. stays free along the building with wall bracing alone looped over two parameter sets, so one it did two shed generations and two full solves inside the 5 s default. 1.65 s on the machine it was written on, over 5 s on a CI runner inside M2's 8100-test pool, where it timed out and failed that PR's web job. Split into one test per configuration: 761 ms and 765 ms, and the failure now names which configuration failed. #164's web job has passed twice consecutively since.

3 · basic-demos.spec.ts — the canvas box is measured before each click. The section walkthrough captured it once and aimed four clicks at it. The sibling test two functions up already carries exactly this correction and its reason. Real defect, fixed.

Correction: fix 3 did not fix the CI flake, and the cause I published for it was wrong

I said the canvas re-measurement would fix the intermittent e2e failure in that spec. It did not — the next run failed the same assertion at the line the fix moved it to. I then published a diagnosis saying the test skips opening the Section Analysis panel, which the arm step leaves to the reader. That diagnosis is wrong, and it would send a reader down the wrong path, so it is retracted here rather than left standing.

resultsStore.stressQuery — the pick step's only advance condition — is written in exactly one place for 2D: the Viewport.svelte click handler, when findNearestElement(world.x, world.y, 0.3) returns a member. The section panel opens as a consequence of that write, not as a precondition for it. So a closed panel is a symptom, not the cause.

What the artefact does establish: the tour sat on Choose the station with its button on Waiting...; the section panel was not open; a probe read a displacement station at x/L = 50.0%; and stressQuery was null. Two candidate causes survive that evidence and it does not separate them — either the four clicks never landed within the 0.3 m capture radius of a member, or the select mode was not 'stress' when they did. I do not know which, and the honest state of this finding is "narrowed, not diagnosed".

main reached the same flake independently in 0844f01d and declared retries on this test, citing runs of feat/pro-steel-m2 as its evidence and labelling itself, correctly, "containment, not a diagnosis". That containment is not yet in this branch — it arrives when main is merged. Until then this test can still redden either PR's e2e job. It is not a defect in this branch's work.


Review summary — scope, limits, and what is deferred

Delivered scope

The metallic PRO foundation: material and section selectors on a GradeSource seam, section properties with a declared basis on every number, the shed's longitudinal load path, and the grade/profile pairing. Full account above.

Merge order, and the dependency

#156 merges first, into main. #164 merges second. #164 is based on feat/pro-steel-m1 and contains this branch by merge — merging #164 before #156, or retargeting it at main, would carry M1's commits in under M2's review. The two are not independent.

Known limitations

  • No metallic export and no metallic verification. steelCountsAsVerified() still returns the literal false; nothing metallic is presented as verified anywhere.
  • The share codec drops four section fieldscomposition, profileFamily, tl, built. Preexisting, concrete and steel alike, documented in docs/handoffs/share-codec-fields.md §1–§6. built-section-contract.test.ts asserts the loss so it cannot be forgotten.
  • Pinned lattice bases carry no out-of-plane restraint, and say so: generator.assume.latticeBasesPinnedNoOutOfPlane.

Non-blocking findings, for M3

Finding Why it is not blocking
The basic-demos section-walkthrough flake, narrowed to two candidate causes (above) Not this branch's surface; main already carries containment; CI green on this head
returns a mechanism wearing a number under a load along the building is 2.89 s against the 5 s default Nothing to split — a single solve, slow because the unbraced shed is near-singular by design, which is the assertion. Shrinking the model would change what it measures
CI has no typecheck and no svelte-check job, no concurrency, and e2e runs --grep @smoke only Owned by whoever owns ci.yml. The @smoke-only gap is what hid finding 1 above for as long as it existed
A darwin screenshot baseline is one pixel wider than the committed one (697 vs 696 px, 3 % over a 2 % tolerance) Fixing it means updating a snapshot. CI runs that job only on main or under run-e2e
Install wasm-pack reports success when its download fails. The step is `curl … sh, so its exit status is sh's — a failed curlleaves the step green and the failure resurfaces two steps later aswasm-pack: command not found, exit 127, with the blame on Build WASM engine. Observed on this PR: curl: (35) Recv failure: Connection reset by peer`
@perf viewport measurements time out under whole-suite CPU contention; 4/4 in isolation Fixing it means inflating a timeout. CI never runs @perf

Nothing classified as blocking

No correctness error, no data loss, no broken main flow. The one item that fell in the false claim category was my own published diagnosis of the flake, and it is retracted above.

Gates on this head

typecheck 479 = baseline 479, no new · unit 7401 pass / 0 fail · build project 19 pass · npm run build ✓ · full local E2E 638 pass · CI lint test suite (1) suite (2) web e2e all pass.

main has moved on since this branch synced with it (b579de87c4540883). Deliberately not chased: the branch is frozen. A dry-run merge of the new main produces zero conflict markers — the one file both touched, basic-demos.spec.ts, is changed in different regions.

Batuis added 29 commits August 19, 2026 23:10
PR21 left every `lookupGrade` call site passing `undefined`, with a comment
saying PR #132's grade catalogue "is not on this branch". It is: the merge
that brought `structural-grades.ts` and `non-metal-grades.ts` into main
(`d1ba4fb2`) is an ancestor of this branch's base. So the lookup is supplied,
and the family of a metallic member stops being a guess about the magnitude of
`fy` and becomes a reading of what the project recorded.

`grade-family.ts` is the implementation of the seam, not a replacement for it:
`material-family.ts` still works with no catalogue at all, which is what keeps
its tests free of one. The non-metals are resolved too, because the material
picker writes a `gradeId` for concrete and timber as well and a lookup that
only knew the metals would file a 60 MPa timber class as concrete.

Two consequences worth stating rather than leaving to be discovered:

  · the panel's "family was deduced" warning disappears for a member whose
    grade is declared, and stays for every model saved before the picker
    carried one. Both are pinned.

  · aluminium separates from steel for the first time. The inference could not
    tell them apart and filed 6082-T6 under steel; the declaration can, so an
    aluminium member now falls out of a list whose admission rule is `isSteel`.
    Dropping rows silently is the failure this file was written against, so the
    inventory says it: a new notice names the non-ferrous members, and a model
    whose only metal is aluminium reports `nonFerrousOnly` instead of the false
    "none of them is metallic".

Nothing about verification moves. `steelCountsAsVerified` still returns the
literal `false`, the four states are unchanged, and a bound authority with
demands present still yields NOT_DESIGNED. The concrete baseline digest is
unchanged at 1bd4d9c1d575b085.

`member-context.ts` and `auto-verify.ts` could take the same lookup and the
same shared ceiling constant. Both are shared files under the overlap
protocol, so they are reported for coordination rather than edited here.
… came from

`PROFILE_FAMILIES.L` is `[...L, ...IRAM_L]` — the EN 10056-1 equal-leg angles
plus the Argentine series tabulated for CIRSOC 301-EL — while
`FAMILY_CLASSIFICATION.L` names EN 10056-1 for the whole family. Eleven of the
rows in that group are IRAM-IAS profiles filed under a European standard, and
PR21 recorded it as an inherited gap it would not close.

The provenance is read from WHICH SOURCE ARRAY a row came from, which is a fact
the tables carry and the merged array loses. `IRAM_L` is imported and its ids
get `IRAM-IAS U 500-558`, the standard its own header names; everything else in
the family keeps the European one. Nothing is parsed out of a name and nothing
is transcribed, so moving a profile between those two files moves the answer.

The family is reported as carrying two standards rather than one of them being
chosen to stand for both: `standardsInFamily` derives the list from the rows,
the picker's group header shows the count with the designations in its tooltip
instead of printing a designation that is false for eleven rows, and the rows
whose standard is not their family's carry their publishing body as a tag.
Filtering by body now works on the row — asking for IRAM-IAS angles used to
return nothing.

`section-catalog.ts` is NOT edited. Basic's own section picker reads
`FAMILY_CLASSIFICATION`, so under the overlap protocol the single-standard
family declaration there is reported for coordination rather than changed. This
adds the per-row detail the merge threw away; it contradicts nothing.

Two test holes closed while here, both in the metallic surface's own i18n gate:
Portuguese key parity was checked by nothing, and the `steel.panel.empty.*`
keys were listed by hand — so the fourth empty reason added by the previous
commit would have shipped rendering its own key. Both are now expanded from the
unions they come from.
…not derive

A PRO section card is expected to show section moduli and radii of gyration —
what a member is actually sized with — and the tables publish neither. One of
them is exact arithmetic on published numbers and the other is not, and the
difference is the whole design of this module.

`sqrt(I/A)` needs only two tabulated values, so a radius of gyration is always
available; it reproduces the published 8.26 cm for an IPE 200 and satisfies the
same identity the tube tables were validated against. A section modulus needs
`I/c`, and `c` — the distance from the centroid to the extreme fibre — is not
in any table. Half the depth is the right answer only where symmetry puts the
centroid at the centre of the box. A UPN 200's centroid sits 20.1 mm from the
back of its web, so half the width gives Wz = 39.5 cm³ where the governing
value is 27.0: a 46 % overstatement that looks entirely plausible.

So `c` comes from `resolveProfile`, which builds the verified canonical outline
and returns the bounding box measured from the centroid, and every quantity
carries its basis: tabulated, derived from the table, derived from the outline,
or unavailable with a reason. Where the section is asymmetric about the axis in
question the MINIMUM modulus is reported, with a note saying it is one of two.
Where nothing can support a value — MC's weak axis, whose flange taper no
outline can fit — the row says so rather than showing a number.

The torsion constant is not derived. It is tabulated for the IRAM tubes and
absent everywhere else, and the standing prohibition is that a polygon-derived
value is not J for a thin open section.

The root radius turned out to mean four different things, and flattening them
would have misreported three: tabulated (IPE), solved from the published clear
web depth (W, C — an inversion, not a column), fixed by the standard as a rule
on the profile's own dimensions (IPN, UPN, the tubes — nothing missing, the
outline is exact), and the three C9 profiles that carry `r: 0`, which
`iram-c.ts` uses to mean "no usable radius, drawn sharp-cornered". A zero shown
as a tabulated value attributes our drawing decision to the standard. Each case
is decided from the family's declared geometry fidelity, not from a hand-kept
list, and each has its own sentence in all three languages.
`structural-grades.ts` is a strong database — it separates the product standard
from the design code, records the thickness bands and the standard that
tabulates them, and marks which values were read from the governing standard.
What it has no seam for is asking questions of it: every surface that wants
grades rebuilds filter-by-family, filter-by-region, filter-by-code and search
inline, which is how two pickers come to disagree about which grades exist.

`lib/grades/catalogue.ts` gives it the shape `lib/profiles/catalogue.ts` gives
the profile tables: query in, entries out, behind a `GradeSource` a project
library could implement. The code filter is delegated to `gradesForCode` rather
than reimplemented — including its decision to return the whole family when a
code matches nothing, because an empty picker looks broken — and it is ignored
rather than guessed at when more than one family is selected, since a design
code covers exactly one.

The card states the authority behind every number, which is the part a PRO user
needs and Basic's picker cannot show. Four authorities appear on one card:
values published in the product standard; the strengths of the 45 grades the
source marks as typical of the alloy rather than read from the governing table;
the shear modulus, derived through G = E/2(1+nu) and labelled as derived — it
comes out at 80 769 MPa for the EN steels, which is the 81 000 CIRSOC 301
chapter 2 quotes, an independent check that the identity is the right one; and
the thickness bands, always shown with the design code that tabulated them and
never with the product standard beside them unqualified.

The pairing verdict is delegated to `isUnusualPairing`, whose three-valued
answer becomes a named verdict so a component never interprets `null`. It is a
note about cost and lead time and nothing is blocked by it.

Basic's `MaterialPresetSelector` is deliberately not reused: it is a modal over
the whole app, it offers concrete and timber, it has no keyboard path and no
region axis, and its only provenance mark is a `~`. The component is a panel,
metals only, and it says above the list that a selection configures the model
and verifies nothing — because there is no metallic authority here to verify
anything.
…surement that found it missing

PR21 solved the generated shed under a vertical load and stopped there. Under a
load ALONG the building the same shed returns 2.4·10^11 m for 20 kN — not a
singular matrix, so every `isFinite` check on it passes. It is the exact failure
mode `pr21-lattice-cap-idealisation.md` §7 was written about, in a direction
nobody had loaded.

The instrument is the one that file established: add one class of restraint at a
time and see which removes the freedom. It answers at once — restraining
translation along the building at every node ABOVE the eaves takes the response
to exactly zero, while restraining the eave line itself changes nothing. So the
free body is the roof, and the reason is structural rather than incidental: a
planar truss with a pin-jointed web has no out-of-plane stiffness, so the whole
top chord, tied frame to frame by the purlins, translates sideways as one piece.

Three members make the path, and none of them makes it alone:

    roof plane → vertical bracing between trusses → eave line
               → eave beams → braced wall → ground

so there are three switches, not one. Each one's contribution is measured by
removing it: roof bracing alone leaves 1.7·10^11 m (a triangulated plate that
still slides — the obvious wrong fix, and now a test); vertical bracing without
a braced wall leaves 1.9 m (the tie reaches an eave line held by nothing but the
columns' weak-axis bending); the full system reaches 4.4 mm, and 2.3 mm with
every bay braced. Eleven orders of magnitude is what separates a load path from
a stiffer mechanism.

`purlins: false` is answered too, and answered in both halves. Bracing only the
end bays leaves the interior frames with nothing holding them sideways, so it
stays a mechanism; bracing EVERY bay supplies the restraint the purlins supplied
and it solves vertically at 4.8 mm — which is a statement about which restraint
was missing, not a suggestion that a roof can do without purlins. The
`roofWithoutPurlins` disclosure stays on the model whatever the bracing, because
it is about the purlins and not about the stiffness.

The braces land only on steel that is already there. `findNode` cannot create a
node, because a diagonal drawn to a computed position would leave a node held by
two bars and nothing else — a free node introduced by the member meant to remove
one — and a test asserts the node count is unchanged in every configuration, so
a silently skipped brace fails rather than shipping a wall with no brace in it.

All three default to false, so the shed a user gets by pressing Generate is the
one PR21 measured. No solver, Rust, WASM or Cargo change: the mechanism was in
the geometry handed to the solver, and the solver was right about it both times.
`GeneratorMaterial.gradeId` has existed since PR21 and had never been given a
value. A generated model took `PLACEHOLDER_STEEL` — A36, which is not an
Argentine grade — and declared `generator.assume.placeholderGrade` to say so.
The handoff recorded that filling it in was a one-line change at the call site,
and this is the call site.

The grade is chosen once and applies to every generated member, because a frame
is fabricated from one steel and a per-role material would be a capability the
generator has no use for. What IS per role is the consequence: the roles do not
share a section family — a shed's chords are I-sections and its diagonals are
angles — so the pairing question has one answer each, and the panel names the
roles whose profiles that steel is not ordinarily rolled in. Cost and lead time,
never correctness, and nothing is blocked.

With no grade chosen the control says what the model gets instead, rather than
leaving a user to conclude the members have no material at all.

Three things are pinned: the declaration reaches the emitted material, the
placeholder disclosure disappears exactly when it stops being true, and a grade
moves no node, section or member — it is one row in one table.

The headline `fy` is used, not a band value. A generator places profiles and
does not size them, so the member's governing thickness is not known here and
resolving a band would be inventing the decision that picks one.
…ows the full card

PR21 replaced a 100-option `<select>` with a searchable popover and said the
general PRO picker would need more axes. These are them, and each one is a
question an engineer asks out loud: which body publishes these dimensions — a
project working to IRAM tables does not want the DIN series mixed in; which
design code, delegated to `familiesForCode` rather than reimplemented; and what
depth range fits, which is the constraint a drawing actually imposes.

The card is what makes it a PRO control rather than a longer list. A section is
chosen on the moduli and the radii of gyration, which the tables do not publish,
so every row carries the basis of its number — tabulated, derived from the table,
derived from the verified outline, or unavailable with the reason. A modulus from
a real centroid and one from half a bounding box are different claims and only
one of them exists here. What the source does not give is a block with sentences
in it, not three blank rows.

Comparison holds up to three pinned profiles, by id, so a pin survives a filter
change: comparing an IPE with an HEA means having both in view while the search
shows one. The pin is its own control — clicking a row picks and closes, and that
primary action must not become ambiguous — and the table gets its own horizontal
scroller so the panel body never scrolls sideways at 1280×720.

Two defects fixed while here, both mine from earlier in this branch: a local
binding named `props` collides with the `$props()` rune and surfaces as a
circular-type cascade rather than a name clash, and the depth boxes were typed as
strings while `bind:value` on a number input writes a number or null — `.trim()`
on that throws at the first keystroke.

The empty state now names every filter that can produce it, including a
half-typed depth. PR21's own message predates these axes and its key lives in the
shared main dictionary, so M1 adds its own in the steel namespace rather than
editing a file H1 may be holding.
… the joints gate covers all three languages

Two closures, both about disclosure rather than calculation.

The member table showed a material NAME, which a user can rename to anything. It
now shows the catalogued grade with the product standard beside it, because two
standards can give the same designation to different steels and a designation on
its own is not a specification. Where the project declared nothing the cell says
which of the two reasons applies — no grade recorded, or a grade the catalogue no
longer knows — instead of being blank. The inventory carries only the id, since
it is pure by contract, and the panel resolves it through the grade source.

The joints panel's 77 `conn.*` strings live in the main dictionaries rather than
the steel namespace, so the parity gate never saw them: the five limitation
disclosures, in three languages, verified by nothing. They are checked now — key
sets identical across es/en/pt, every facet of every limitation present, with the
ids read off the panel source so a sixth limitation added without its five
sentences fails here instead of rendering its own keys. The rule that a warning
affecting a result sits beside the result is asserted by position too.

PR21's never-verified guard caught one of my own strings: a note describing the
canonical outline as "verified" tripped a check that exists to stop the word
appearing anywhere metallic except as a denial. The guard is right and the
wording was lazy — it now says what verification meant there, that the outline's
area and inertias reproduce the published ones.

The joints panel needs no behavioural change from M1. Its metallic filter reads
the same inventory verdict, so wiring the grade catalogue made it stricter for
free: an aluminium member is no longer offered a bolt group. One consequence is
recorded for coordination rather than edited — `conn.gap.aluminium.scope` says
the metallic inventory still lists aluminium members, which stopped being true
this branch. The string lives in a shared file; the correction is proposed in the
M1 handoff.
… overlaps left for coordination

Records the four things a reader of this branch needs and cannot reconstruct:
the verification of where M1 starts, the measurement that shows PR #151 carries
no selectors and #132 does, the eleven-orders-of-magnitude table behind the
longitudinal load path, and the four shared-file items reported rather than
edited.

Also records what M1 leaves open, including one correction it owes to a document
it did not edit: §8 of the lattice-cap handoff still says the cause of
`purlins: false` is not demonstrated, and the comment `606ca6e4` left in
`shed.ts` demonstrates it. That file belongs to PR21, which is in review.
…promised, and the audit that replaces two shared-file edits

## The four shared-file points

`docs/handoffs/m1-h1-coordination.md` documents each with the eight answers the
overlap protocol asks for: problem, files and lines, proposed change, owner,
impact on the other branch, tests required, safe alternative, and who should
implement it. None is implemented; none of those files is touched.

Owners proposed: the duplicated `CONCRETE_FY_CEILING` and the `profileSelector.*`
consolidation belong to a common integration; passing the catalogue to
`member-context` belongs to H1, because it can change which members get designed
and the concrete pipeline is not M1's domain; correcting
`conn.gap.aluminium.scope` belongs to M1 with authorisation, because M1's own
change is what made the sentence false and Uniones metálicas is M1's phase.

Two of the four have safe alternatives M1 can do without coordinating, and both
are implemented here instead of the shared edit:

  · the threshold that exists twice is now tied by a test that reads the local
    copy through `rcCheckability` and requires it to agree with the exported one
    across the whole range. With one constant it is a tautology; with two it is
    the assertion that makes a future divergence red instead of silent.
  · PR21's seven picker keys live in the main dictionaries, which the steel i18n
    gate does not read — so they were covered by nothing. Covered now, by reading
    those files without editing them, the same way M1 covered the 77 `conn.*`
    keys that were in the same position.

## The document PR21 pointed at

§8 of `pr21-lattice-cap-idealisation.md` says the cause of `purlins: false` is
not demonstrated and refers the reader to "the document of that investigation".
That document does not exist — I searched `docs/` — and the claim was already
contradicted by PR21's own later commit `606ca6e4`, whose comment in `shed.ts`
measures it.

`docs/handoffs/m1-purlins-false-investigation.md` is that document, with each
half attributed to whoever measured it: PR21's restraint experiment identifying
out-of-plane translation, and M1's finding that the default shed has no
longitudinal load path at all. It does not edit PR21's document — that belongs to
#135 — it supplies what the reference pointed at.

It also corrects an argument of §7 of `pr21-integration.md` with measurements
rather than with an opinion: generating the longitudinal bracing was supposed to
justify pinned lattice bases again. It does not. Pinned bases already solved
vertically before any bracing — that is the cap idealisation, documented in §4.1
of the cap handoff — and under longitudinal load neither the wall bracing nor the
roof bracing helps, because the missing member was the vertical bracing between
trusses. Four tests pin it.

## The metallic surface, audited across its modules

`steel-never-verified.test.ts` enumerated five component paths by hand and M1 had
added four components to those same directories, none of them covered. It now
walks the directories, so the newest screen — the one most likely to break the
commitment — is covered by default, and the audit is widened: no passing tone, no
bare tick outside the joints panel, and no state named that the engine cannot
produce.

`steel-surface-audit.test.ts` checks what only exists across modules: the five
joint limitations are still those five, pinned by name so a swap cannot pass; the
set of authorities behind every displayed number is closed; a picker never imports
a table, so both catalogues stay pointable at a project library; and provenance
resolves everywhere — every standard published, every band attributed to a design
code and never to the product standard, every id the model can store resolvable
in both catalogues.

Source untouched this turn: tests and documentation only.
…n, and an executable QA checklist

Source untouched: tests and documentation only. None of the four shared-file
points is implemented, and none of those files is touched.

## Point 3 — prepared, not applied

H1 is adding `design.floor.state.*` to the same three dictionaries, so the
one-line fix waits. What is ready:
`docs/handoffs/patches/conn-gap-aluminium-scope.md` carries the three diffs, the
location BY KEY rather than by line — in `pt.ts` the key sits in a different
region of the file, so a line-numbered patch would not apply — the impact, and
the apply procedure. `conn-aluminium-scope.test.ts` (10 tests) pins the behaviour
that makes the sentence false, checks the proposed text against the properties it
has to have in all three languages including the never-verified rule, and asserts
the shipped state so the wait is visible. Applying the patch inverts ONE
assertion, marked in the file.

Shadowing the key from the steel namespace stays rejected: the merge order means
it would win, and H1's edit to the same key would vanish without an error.

## Point 2 — the observation the decision needs

`member-context-lookup-observation.test.ts` (9 tests) injects the lookup at its
own call, so nothing shared is touched and every assertion stays true before and
after H1 wires the call site. The metallic half is a no-op, as the point said.
What the wiring also fixes is not metallic, and is why H1 should read it:

**every catalogued timber class is currently handed to the concrete pipeline.**
EN 338 runs C16 to D60, all at or under the 80 MPa ceiling, so the inference
answers `concrete` and a C24 beam is designed as 24 MPa concrete. With the lookup
the declared grade says timber and it is excluded.

Two smaller findings for whoever is in the file: `ContextModelData.materials` does
not declare `gradeId`, and `MemberContext` keeps `verdict.family` while discarding
the basis, so a concrete surface cannot say whether a family was declared or
guessed — the distinction the metallic panel shows.

An earlier draft of that file read a `familyBasis` that does not exist and passed
on `undefined`. It now asserts the absence deliberately, because a vacuous
assertion is worse than none.

## QA, the five gaps, and the pinned-bases question

`m1-qa-checklist.md` is executable against 127.0.0.1:4004 and starts by verifying
which worktree that port serves — the trap that already cost a session on 4173.
It covers materials, grades, profiles, provenance, the three generators, purlins
and bracing, joints, the five limitations, states, the three languages, 1280×720
and accessibility, with the numbers to expect where a wrong one would still look
plausible: Wz between 20 and 30 cm³ for a UPN 200, because 39 means half the width
was used. It closes with what automation already covers, so nothing is
re-tested by hand, and with the four findings that are not bugs.

The five joint limitations now have a canonical table outside the app, with the
`affects` value each — the field that separates a limit state nobody computes from
a number that exists and is not drawn.

Pinned lattice bases are recorded as an M2 candidate with the four things a
reopening would need, and explicitly out of M1's scope: no new investigation, no
change to the current idealisation, no lateral hypothesis without a measurement.
…e checklist half that is now automated

Source untouched: an e2e spec, a test comment, and documentation.

## The contract H1 asked for

`m1-grade-family-contract.md` states `catalogueGradeFamily` definitively:
location, signature, inputs, outputs, consumers, and what happens when `gradeId`
is missing — which is the part that matters, because the answer is "nothing
changes". Passing the lookup is additive, not a migration: a model with no
declared grade, or one declaring a grade the catalogue no longer knows, keeps the
`fy` inference exactly as today.

`null` is the load-bearing part of the return type. It does not mean `unknown`,
it means this catalogue cannot answer, and the caller must fall back. Returning
`unknown` would report a material with a plain strength as unclassifiable.

The counts in the table are read out of the arrays rather than estimated — 27 / 20
/ 11 / 10 metallic, 25 concrete, 13 timber; my first draft had three of them
wrong. The one row that moves when H1 wires the call site is timber, and it moves
toward correct: every EN 338 class sits under the 80 MPa ceiling, so a C24 beam is
designed as 24 MPa concrete today.

Two neighbourhood findings for whoever is in the file: `ContextModelData` does not
declare `gradeId`, and `MemberContext` discards the family's basis. M1 does not
add either — shared file.

M1 commits to not changing the module without coordinating.

## M2, with what each item needs

`m2-candidates.md` covers the five: plastic modulus, closed-section torsion,
comparison highlighting, bracing sizing, more languages. Each says what is
needed, what must NOT be done, and the acceptance criterion. None is started.

The useful part is why each one is not trivial. `Wpl` needs the plastic neutral
axis, not the centroid, and they differ for every asymmetric section — but EN
10365 tabulates it for 56 profiles, which is the validation route. Bredt has the
same shape: 100+ IRAM tubes publish `j`. Comparison highlighting is blocked on a
product decision, not code: "better" is not a property of a row — more mass is
worse, and the governing radius of gyration is the weak-axis one. Bracing sizing
is blocked on a demand that does not exist and an authority that does not exist,
with slenderness as the honest intermediate step. And within the languages,
translate the limitations before the labels: an English label is a nuisance, an
English limitation is a risk.

## The checklist, half of it automated

`m1-steel-selectors.spec.ts` — 25 tests, each naming the checklist item it
discharges — covers the mechanical half through the click path a user has: the
two-standard angle group, body filtering on the row, the depth bounds, the basis
badges as text, the pinned comparison surviving a filter, the band standard, the
typical mark, the bracing notice, the grade column, the three languages, and
1280x720 with no sideways scroll.

Writing it found two things the checklist claimed that the app does not do, and
both were mine:

  · a half-typed depth does NOT empty the list, because `input[type=number]`
    refuses a non-numeric keystroke — Playwright fails outright on `fill('-')`.
    The NaN branch in `queryProfiles` is defensive, not reachable from that
    control, and the unit test now says so instead of implying a UI path.
  · G renders as `81 GPa`, not `80 769 MPa`: the card switches to GPa above
    10 000 MPa. The value was right, the unit in the checklist was not.

Both corrected, and recorded in the checklist as corrections so a reviewer does
not go looking for the wrong number. The checklist now also states what stays
strictly manual: whether things fit, whether a notice lands at the right moment,
and whether the three languages read like a person wrote them.
…tory lists those members

Applied now that H1 published `ad4192e6`, which was the condition. Their commit
adds `design.floor.state.*` as one contiguous block around line 5961 of each
dictionary and touches no `conn.*` key, so the two changes never meet — and their
own note in that block anticipates this one and asks that it not be interleaved
with theirs. It is not: this is a value replacement of an existing key near line
4341, not an insertion.

Applied BY KEY NAME, never by line number. In `pt.ts` that key lives in a
different region of the file — 3535 against ~4341 — so a line-numbered patch
would have rewritten the wrong row in one of the three languages.

## What was wrong

`conn.gap.aluminium.scope` said the joints of aluminium members "can fall outside
this list even though the metallic inventory does list them". Wiring the grade
catalogue in `6d274e37` ended the second half: the inventory admits rows by
`isSteel`, so an aluminium member is not listed there either — it is NAMED, in a
notice, and a model whose only metal is aluminium reports `nonFerrousOnly`.

The sentence was not dangerous — it still warned about aluminium and still said
the bolt and electrode tables are steel's — but it described behaviour the app no
longer has, in the one block whose entire worth is that it can be believed. And
M1 caused it.

## What the new text does

States that the joints fall outside the list, that the inventory does not list
them EITHER, and what happens instead: it names them. Then keeps the half that
was always right, that the tables are steel's and would be wrong for aluminium
even if the joint appeared. The sibling key `conn.gap.aluminium.missing` is
untouched: it says `materialFamilyOf` cannot tell aluminium from steel by
magnitude "until the material declares its grade", which is still literally true.

## The guard, inverted rather than deleted

`conn-aluminium-scope.test.ts` asserted the pre-patch text so the wait could not
be forgotten. It now asserts the corrected values, that the false clause is gone
in all three languages, and that H1's floor-state keys are either absent or all
seven present — never the partial set a careless conflict resolution leaves. One
assertion in block 2 compared the proposal against what was shipped; that one
belonged to the pre-patch world and is now a check on the sentence itself.

Locale parity is unchanged because no key was added: es 5572 / en 5571 / pt 4590,
identical to `08917b9f`. `conn.*` is 77 in all three. The 1176-key Portuguese gap
in the main dictionary predates this branch and is not M1's to close.
…t, and the contract ships on its own branch

## The contract, delivered in isolation

`contract/grade-family` at **`168320b0`**, cut from `08917b9f` — the base M1 and
H1 share — with two new files and zero edits:

    web/src/lib/engine/steel/grade-family.ts                          86 lines
    web/src/lib/engine/steel/__tests__/grade-family-contract.test.ts 148 lines

`git cherry-pick 168320b` and nothing else comes along. M1's own commit for that
module, `6d274e37`, touches seven files — the inventory, the metallic store, three
steel dictionaries — so it was the wrong unit to hand over.

The test is deliberately not M1's. `grade-family.test.ts` asserts the contract AND
its consequences for the metallic inventory, which depend on M1's changes to
`steel-inventory.ts`. The one on the contract branch depends only on the module,
the catalogue and `material-family.ts`, all unchanged on the base — **12 tests,
green on `08917b9f` with nothing of M1 present**, which is the proof that the
contract is self-contained. Section 11 of the contract document has the commands.

## §3 and §4 of the checklist, automated

`m1-generators-joints.spec.ts` — 14 tests — covers what was manual-only: the three
generators previewing and following their parameters, a refusal tied to the button
by `aria-describedby` and recovering, the promised count landing for all three
kinds including the shed that merges nodes by coordinate, a properties-only
profile refused for compound arrangements with its reason on screen, a generated
model reporting no results rather than pretending, and the bracing switches being
additive. Then the joints panel: banner before any number, the four sub-sections
in order, the non-metallic count, the FvExcl warning beside the result and only
for 4.6 and 5.6, the five limitations with their four facets each, torsion marked
as NOT affecting the result unlike the other four, and the corrected aluminium
sentence as it reaches a user.

Three more checklist claims were wrong, and the tests found all three:

  · the arrangement select of a profile row is neither the first select on the
    panel (that is the truss kind) nor addressable by label alone (that matches
    all three role rows: 15 options, 1 refused plus 7 and 7). The app was right
    both times; my locator was not.
  · `portico2d` is not in `fixture-index.ts`. Loading it yields an empty model and
    the failure surfaces as a timeout rather than "no such example".
  · the checklist asked for a mixed model without saying how to get one.

All three recorded in the checklist as corrections, alongside the two from the
previous turn, so a manual reviewer is not sent looking for something that is not
there. What stays strictly manual is now explicit: whether things fit, whether a
notice lands at the right moment, whether the prose reads like a person wrote it,
and the state sequence a user actually walks.
…the grade-family copies verified identical

Source untouched: one e2e spec, one document, one checklist update.

## The contract: identical, and the merge is a non-event

H1 has the same `grade-family.ts`. Verified rather than assumed, read-only, no
cherry-pick and no integration:

  · byte-for-byte identical across all three refs — M1, `contract/grade-family`
    and `origin/feat/pro-concrete-h1` — digest `f9ccb531308b14d2`, 86 lines, not
    a whitespace apart;
  · it arrived independently. `168320b0` is NOT an ancestor of H1; the file landed
    inside their own `23ce3e34`, and their commit message says they took the
    module as-is rather than reimplementing it because two functions answering one
    question become two answers;
  · `git merge-tree --write-tree` reports zero conflicting paths. Git resolves an
    added-on-both-sides file without flagging it when the content matches, so the
    physical consolidation is not pending work with risk — it is a non-event.

So `contract/grade-family` is no longer needed as a delivery vehicle. It stays
published because it documents the contract with a self-contained test; deleting
it is the integration's call, and the policy's condition for deleting a branch is
already met — the work provably exists elsewhere, with the same digest, on two
branches.

The two test files do not overlap. Mine covers the whole catalogue, non-
normalisation of input and the consequences for the metallic inventory; theirs
covers the pipeline admission and the timber defect, and asserts something mine
does not: that timber and masonry belong to neither pipeline. They complement.

One observation stays open and is not M1's: `ContextModelData.materials` still
does not declare `gradeId` in its type. H1's message confirms the field always
arrived because the live `modelStore.materials` is passed, so it works; the
interface still does not name it.

## §5, the warning timing, and the languages at runtime

`m1-states-and-languages.spec.ts` — 18 tests — closes the last automatable part
of the checklist. The states as a SEQUENCE, which is why they were manual: an
unsolved model reporting the demand as unavailable in words rather than by colour,
a solve that moves the state and never to a pass, an empty model saying it has no
elements rather than no steel, an all-concrete model showing the census that turns
"no steel" into "400 members and none of them are", and the banner that has no
close control anywhere inside it.

The language checks read RENDERED text on every metallic panel, not the
dictionaries: `t()` returns its own argument when a key is missing, so a key
printed into a panel is the failure mode and only a rendered page shows it. The
same pass audits for the four approval words outside a denial — the runtime half
of `steel-never-verified.test.ts`, which reads source.

The warning-timing audit found no defect and one thing worth writing down: the
FvExcl warning is conditioned on the GRADE, not on the threads checkbox its own
comment names. The code is the more conservative reading — it appears on selecting
4.6 or 5.6 before any result exists, so a user who never touches the checkbox is
still told that unticking it would change nothing. Pinned, along with its
disappearing for 10.9, because a warning that fires where it does not apply
teaches the reader to ignore it.

57 automated tests now cover the checklist. What stays manual is explicit: whether
things fit, when a notice lands, whether the prose reads like a person wrote it,
and the two §5 items that need the regulations panel and a hand-edited material.
…e not the debt they look like

Source untouched: two documents, one unit gate, one e2e spec. Nothing translated,
which is the point.

## Three corrections to the premise

The block asked for an audit of "the 12 languages that still fall back to
English". Measured:

  · **eleven, not twelve.** The app ships fourteen dictionaries; three are
    offered.
  · **none of the eleven is reachable.** `OFFERED_LOCALES` is es/en/pt,
    `detectBrowserLocale` is TYPED to return an offered locale, and
    `getInitialLocale` refuses a stored locale that is no longer offered — with a
    comment saying honouring it "would resurrect exactly the half-translated
    state this exists to remove". The app narrowed to three on purpose. Forcing
    `stabileo-lang=de` does not give German; it falls through to detection.
  · **the eleven are not empty, which is worse.** Each already carries 22
    `conn.*` labels written before the metallic namespace existed. So offering a
    fourth locale is not "translate 314 keys": it is translate 292 and reconcile
    22, and a German user today would see 22 German strings among 292 English
    ones inside the joints panel.

So translating the namespace into the eleven changes nothing anyone can see. The
real question is which locale gets offered next, and that is a product decision
M1 does not take.

## What was worth doing instead

The 314 keys are classified by what they DO, because that is what decides
translation order: 197 labels, 64 warnings, 24 assumptions, 21 help strings, 8
state labels and descriptions. **96 of them change how a result is read** — a
warning explains why a number is absent, an assumption travels onto a report, and
a state is the difference between "nobody tried" and "something was computed with
no authority behind it". Those go first; the labels can wait.

The counts come from the same categoriser the test uses. A first draft of the
document said 193/66/30/4 from a looser regex that also matched keys with
`.assume` in the middle; the test is now the source and the document cannot
outlive it.

`m1-steel-terminology.md` is the artefact that survives whichever language comes
next: eleven terms whose translation is a decision, not a lookup, with
alternatives and the criterion for choosing. Five have little or no margin. The
worst is "experimental" — it must say there is a number with no authority behind
it, never "this is in beta" — and the French section modulus, where three terms
circulate and EN 1993's own French edition is the tiebreak. For ja, ko, zh, ru,
ar, hi, tr and id I propose nothing: I have no basis, and proposing would be
inventing.

## Gates

The unit gate asserts the namespace is complete in every OFFERED locale, so the
half-translated state comes back as a red test if `OFFERED_LOCALES` grows without
the namespace growing with it. It also catches the inverse — identical es/en
values, which are usually a paste. That found one legitimate exception:
`steel.status.EXPERIMENTAL` is "Experimental" in all three, a cognate rather than
a copy, and it is a named exemption rather than a loosened rule.

The e2e spec pins the commitment in a browser: a stored `de` does not survive the
boot, the joints panel reads as one language rather than leaking the 22 German
labels, no raw key renders after a fallback, and each offered locale holds the
props card on its own instead of falling through to English.

## Stopped before the shared file

Offering a fourth locale needs two edits to `lib/i18n/store.svelte.ts:36-41` and
`:73`, and adding a value to `OFFERED_LOCALES` commits the whole `design.*`
namespace — most of the work would be concrete's, not steel's. Reported in the
audit §7 with the contract, the impact and a proposed owner: product first, then
a common integration.
…e AA failure found

Nothing implemented. `tokens.css` and all six consumers are untouched, and stay
that way until H1 and M1 agree the contract.

## The three tokens do not exist

`--st-danger-bg`, `--st-warn-bg` and `--st-provisional` are all undefined today.
`OutcomeBadge.svelte:82` already says so in as many words — "`--st-provisional`
is owed". What exists is the role TEXT colours and one tokenised tint,
`--st-selected-bg`; every other state background is hand-written `rgba()`, and
they do not agree: three values and two hues for warn, two alphas for danger, six
literals for provisional.

## The defect measuring turned up

`OutcomeBadge .badge-fail` and `DesignToolbar .banner-block` put `--st-accent` —
the brand vermillion — as text over a red tint. Composited over `--st-surface`
that is **3.86:1 and 3.93:1, both under the 4.5 AA floor for small text**.
`--st-danger`, which exists for exactly this, is 5.05:1 on the same background.
Those two rules reach for the brand colour where the status colour belongs.

The test asserts it AS FAILING rather than skipping it, so the suite cannot be
green while the screen is not, and so repairing it turns the file red instead of
leaving a stale exemption behind.

## What is proposed

`--st-danger-bg: rgba(192,57,43,0.14)` and `--st-warn-bg: rgba(184,134,11,0.14)`
— the PLAIN hue, not the `-text` one, because a background is area and the
palette already splits those two uses. 14 % rather than 16 %: at 16 % danger on
`--st-surface-2` is 4.86, still AA with 0.36 to spare; at 14 % it is 4.96.

Provisional gets THREE tokens, and that is measured rather than tidy:
`#a066d3` as TEXT on its own tinted background is **3.55 — under AA**, while as a
border or figure it is 4.30, which clears the 3.0 non-text floor. So
`--st-provisional` (fill, the 3-D authority value), `--st-provisional-text`
(#d8b4ff, 7.92 on the tint) and `--st-provisional-bg`. One token would invite the
failing use.

`--st-ok-bg` and `--st-info-bg` are deliberately excluded: `ok` never diverged,
and neither `info` use is a risk banner. Widening the shared surface without
closing a defect is not worth a coordination round.

## The constraint that makes it delicate

`three/rebar-scene.ts` owns `0xa066d3` as a numeric hex for a Three.js material
and cannot read a custom property. Three existing assertions lock the agreement
by literal — `run-summary-reported.test.ts:122` and
`viewer-design-system.test.ts:79,81` — so a naive tokenisation breaks two of them
and, worse, deletes the guarantee that the viewport violet and the panel violet
are the same colour. The proposal replaces "both files contain the literal" with
"both resolve to the same value", in that order: token, then equivalence test,
then consumers. Backwards there is a window where nothing checks it.

`FloorFamilyStateCard` calls provisional amber in two places and should move —
but only after the token and its tests exist, or it references an undefined
property, which is the silent failure `design-tokens-resolve.test.ts` was written
to catch. And its two uses need different tokens: a border takes
`--st-provisional`, a small label takes `--st-provisional-text`.

## Owner and scope

Five of the six consumers are concrete's, the AA failure is in two of them, and
`FloorFamilyStateCard` is H1's. `SteelStatusBadge` needs no change at all — its
warn tone is a deliberate diagonal hatch, distinguishable without hue. So:
implementation H1, `tokens.css` a common integration, M1 contributes the contract,
the numbers and the contrast test.

One first-draft error worth recording: the test claimed `--st-amber` fails as text
because the palette's sentence reads as if it covers all four plain hues. It does
not — amber is 5.20 and passes, while red, green and blue fail. The number is the
authority and the argument for splitting the violet never needed the analogy.

Gates: typecheck 479/479 with no new errors, unit 7066 passing, check:gate clean,
smoke 284 passing on E2E_PORT=4305, concrete digest unchanged.
…lure — this one M1's own

H1 published its own proposal in `54ba5023`, written without seeing M1's. The
shared contract is still NOT implemented: `tokens.css` is untouched on both
branches, verified on both.

## They converge, at the value

Both propose `--st-danger-bg: rgba(192,57,43,0.14)`, `--st-provisional: #a066d3`,
`--st-provisional-bg: rgba(160,102,211,0.16)`, the three-token provisional split
for the same measured reason (#a066d3 as text on its own background is 3.55 and
fails AA), Three.js keeping the numeric authority with the panel dots left
literal and a mirror test comparing values, and the same migration order with
`OutcomeBadge` last. Two branches measuring separately and landing on the same
alpha and the same split is the signal the contract is right.

## Four differences, and M1 concedes one

**`--st-provisional-text`: H1's `#c08ae6` beats M1's `#d8b4ff`.** H1's argument is
that the existing `-text` variants live in a band — 4.97 to 7.52 on
`--st-surface` — and the new one should enter it. Measured, `#c08ae6` is 6.46 and
sits inside; `#d8b4ff` is 9.58 and sits above all four. M1's argument was "it is
what OutcomeBadge already uses, so nothing changes visually", and that was weaker
than it looked: `ProvisionalBanner` already uses two violet text values, so there
was no canonical one to preserve. Conceded.

**`--st-warn-bg` alpha: M1 asks for 0.14 on H1's own stated rule.** Both clear AA
everywhere and the gap is 0.17 of ratio, so this is not an accessibility call. But
H1's §2 says the alpha "copies the only precedent, `--st-vermillion-dim` at 0.14",
then uses 0.14 for danger and 0.16 for warn. Either the token follows the
precedent or the sentence goes.

**`FloorFamilyStateCard` needs BOTH tokens.** H1's §3 table says
`--st-provisional`, their §5 step 4 says `--st-provisional-text`. The card uses
the hue in two roles: a `border-left-color` (3.0 floor) and a label `color` (4.5
floor), and `#a066d3` is 4.30 — fine as a border, not as text. So border →
`--st-provisional`, label → `--st-provisional-text`.

**The AA failure is absent from H1's proposal**, and that is the one with
consequence. Their tables measure the NEW backgrounds and all pass; what they do
not mention is that rules which exist today put `--st-accent` on a tinted
background and fail. Tokenising the background does not fix it — 3.86 becomes 3.84
— the text colour is what fixes it, and three of those files are theirs.

## The fourth failure, and it is mine

The test M1 wrote to forbid exactly this on metallic surfaces failed against
PR21's own joints panel: `.conn-ratio-badge.st-fail` puts `--st-accent` on a 20 %
vermillion tint, 3.55 on `--st-surface` and 3.41 on `--st-surface-2`. Its two
siblings pair each tint with its own role colour; only the failure case reached
for the brand.

Not fixed, and the reason is the contract: at 20 % even `--st-danger` reaches only
4.46 on `--st-surface-2`, so that badge needs a lower alpha — `--st-danger-bg` at
14 % gives 4.96 — or `--st-text`. Choosing now would pre-empt what H1 settles. It
is named, measured, and listed by name in the test so a NEW instance fails rather
than joining a tolerated set.

**Four, not three.** And one correction each way: H1's inventory says
`SteelStatusBadge` "inherits without being edited" because it "references
OutcomeBadge" — it does not, it imports only `i18n` and `steel-status` and owns
its four tone classes. Their conclusion is right for a different reason: it has no
tinted danger or warn background at all, because its warn tone is a diagonal
hatch. And H1's inventory corrected M1's: with `VerificationDetail` counted, the
warn family is six tinted backgrounds across two hues and five alphas, not the
three M1 reported.

One overreach removed: a first draft banned `--st-accent` as text anywhere
metallic. That forbids the correct use — it is the primary-action and selected
token, and the pickers use it to mark a chip on and a row selected. The rule is
about a tinted status background, not about the token.

Gates: typecheck 479/479 no new, unit 7077 passing, check:gate clean, the three
metallic e2e specs 57/57 on E2E_PORT=4306. No shared surface touched.
… label on their own tint

H1's contract is verified and NOT duplicated. `dfa20d8b` ships the five tokens
with every value matching the reconciled proposal, and on the two contested
points it took M1's: `--st-warn-bg` at 0.14, and `--st-provisional-text` at
`#d8b4ff` — the value M1 had conceded away and H1 reverted with the better
argument, that 9.58 beats 6.46 and it is what `OutcomeBadge` already ships, so
adopting the token moves no pixel there. No consumer was touched by that commit,
deliberately, so M1 could check the contract before anything depended on it.

`tokens.css` is not edited here and no shared consumer is. This commit is one
metallic file.

## The fix, and the fifth failure

`.conn-ratio-badge` reports a bolt group's or a weld's governing ratio as a label
on a tinted fill. Composited over the two grounds the panel sits on:

    .st-ok    --st-ok      3.75 / 3.64   under AA
    .st-warn  --st-warn    5.22 / 5.03   fine
    .st-fail  --st-accent  3.55 / 3.41   under AA, and the brand colour at that

Two of the three were illegible. The brief named `.st-fail`; `.st-ok` was found by
the very assertion the brief asked for — a 4.5 floor for text on a tinted
background — and hiding it would have meant weakening the test to fit the
instruction. Amber passing and the other two failing matches the palette-wide
finding that amber is the bright exception among the plain hues.

`--st-danger` does not rescue `.st-fail` either: 4.46 over `--st-surface-2`. At the
contract's 0.14 it would be 4.96, but `--st-danger-bg` is not on this branch —
referencing it would be an undefined custom property, which draws nothing at all
and is exactly what `design-tokens-resolve.test.ts` was written to catch.

So the role moves to the border and the label goes neutral: `--st-text` is 10.3 to
13.1 on these fills, and a border needs only the 3:1 that WCAG 2.1 §1.4.11 asks of
a non-text boundary, which all three clear. It is the pattern H1's own migration
settled on for `DesignToolbar .banner-block`.

All three are changed, not just the two that failed. They are a set read one after
another, and leaving `.st-warn` as the only badge whose label carries the hue would
make the difference between states look like it meant something it does not. The
base class now reserves a transparent border so a change of status does not shift
the row by 2 px — the same thing `OutcomeBadge` does, for the same reason.

The tints stay literal, and a test fails the day `--st-danger-bg` appears here, so
migrating them is not forgotten.

## Tests

`steel-surface-colour-rules.test.ts` grew the five distinctions the brief asked
for — failing on a tint, warning on a tint, correct on a tint, legitimate
`--st-accent` selection, and a 4.5 floor for every label — plus the size
assertion. The known-instance list for accent-on-red-tint is now empty and stays
as a guard rather than being deleted: the shape of the defect is what needs
watching.

`--st-accent` is untouched wherever it means selection: the family chips, the
selected row and the active pin in both pickers. A first draft of this rule banned
it outright on metallic surfaces, which forbade the correct use of the
primary-action token.

`SteelStatusBadge` is unchanged. Its diagonal hatch is a deliberate signal —
readable with hue ignored — and it is not a status background.

Gates: typecheck 479/479 with no new errors, unit 7083 passing, check:gate clean,
42 metallic e2e on E2E_PORT=4307, smoke 284 on 4308. Concrete digest untouched.
…d the selector/model boundary pinned

The brief asked for a feasibility study before implementing. It changed what
there was to implement: of the six goals, four are substantially done, and the
main risk in this block was re-building them.

## What the audit found, measured not assumed

**Regulation** — selectable (CIRSOC 301:2018 and EN 1993-1-1 in `roles.ts`),
experimental-marked, and **persistent**: this is where my previous report was
wrong. The bindings are not in `file.ts`; they live on `modelStore.model.regulations`
and travel through `snapshot()`/`restore()`, which is what all four persistence
paths share. `regulations-persistence.test.ts` already pins it.

**Materials** — done. `lib/grades/catalogue.ts` sources Basic's own
`structural-grades` and exposes designation, standard, family, origin, thickness
bands with the code that tabulates them, and the authority behind each number.

**Profiles** — done, and "thin-walled" splits in two: parametric thin shapes
EXIST (`THIN_SHAPES`, offered by `ProSectionsTab`), while a cold-formed C/Z
CATALOGUE does not and is already declared absent in `section-catalog.ts:121`.
Adding it is table acquisition and validation, not wiring. M2.

**Built-up sections** — they already render correctly in "Modelo con secciones":
`create-element-mesh.ts:98` calls `createSectionShapes` when the mode is
`sections`, and that reads `composition`. Confirmed in the code rather than
assumed, and `built-up-extrusion.test.ts` holds it.

**Workflow** — does not exist, and reusing `StageSection` for it needs H1. M2.

## Implemented

**The code edition and the support maturity on `SteelPanel`.** A project could
declare CIRSOC 301 and the panel said so without saying which text — and the
concrete role carries two editions, so an edition is a real choice a reader could
not see. Both fields are already on the binding, copied at bind time precisely so
a stored project stays readable.

The catalogue's own `noteKey` — the sentence saying the official text ships with
the app and what is missing is the adapter — is NOT on the binding and there is no
lookup by `adapterId`. Adding one means editing the shared regulation catalogue,
so it is reported instead.

**The selector/model boundary, pinned.** "Changing a section in the selector is
reflected in the generated model" has two answers and only one is right: before
Generate it is reflected, after Generate it must NOT be, because a generated model
is geometry in the store and mutating it from a form the user has moved on from
would leave undo nothing coherent to step back to. Nine tests, including mutating
the very spec object that was passed in — the strongest form of the check — and
the property that keeps "no parallel catalogues" true across all three generators.

## Stopped, with the contract written

The most valuable task — recording how a parametrically built section was built,
so the viewer draws its real outline instead of falling to `createSectionShape`'s
I-beam default — needs a new field on `interface Section`, which lives in
`model.svelte.ts`. My own study first claimed an alternative avoided that; the
alternative is false, and verifying it rather than assuming it is what caught it:
`Section.composition` names a catalogue `profileName`, which a parametric section
does not have, and inventing one would be the very defect that field closed.
`ModelProvenance` is model-level, not per-section.

So the field is proposed with its doc comment, its impact, and a common
integration as owner. Nothing was edited, and the hole is not filled with a fake
`composition`.

`SectionShapeBuilder.svelte` turns out to be orphaned — i18n keys in ten locales,
imported by nothing — while the capability stays reachable through
`SectionChanger` and `ProSectionsTab`. Recorded; deleting or mounting it is a
product call with no functional urgency.

Gates: typecheck 479/479 no new, unit 7092 passing, check:gate clean, 28 metallic
e2e on E2E_PORT=4309. No forbidden file touched.
Adds one optional field to `interface Section`:

    built?: { shapeType: string; params: Record<string, number> }

## What was missing

A section reaches the model three ways, and only two could say where they
came from: a catalogue pick carries `profileFamily`, an assembly carries
`composition`, and a section built from a template carried neither. Its
DERIVED numbers were stored — `a`, `iy`, `iz`, `j`, the shape tag, the
thicknesses — and its INPUTS were discarded. So a built section could not be
re-edited (nothing recorded what was typed), and it was the one kind with no
answer to "where did this come from".

And a parameter the apply path forgot was invisible. `computeSectionProperties`
takes `tl` — the lip thickness of a cold-formed channel — as its own input and
returns it; `handleShapeConfirm()` enumerated the fields by hand and that one
was not on the list. `createSectionShape`'s `case 'C'` substitutes the FLANGE
thickness when `tl` is absent, so the properties were computed from one lip and
the outline drawn from another. They agreed only while a user left `tl = tf`,
which is the template default (0.009 m both) and the reason it went unnoticed.
This commit passes `tl` as well.

## Why it is additive

`snapshot()` destructures the whole section (`const { canonical: _drop, ...rest }`)
and `restore()` copies it, so neither had to change: `.ded`, undo/redo and tab
capture carry the field already. The field is optional, so every stored model
predating it stays valid.

Declarative, like `composition`: nothing in the properties path reads it. The
stored `a`/`iy`/`iz`/`j` remain authoritative and the canonical resolver is not
handed a second opinion about the geometry.

No fabricated `composition` — `profileName` is documented as an exact catalogue
name and a built section has no catalogue part to name. Not `ModelProvenance`
either: that records where a whole MODEL came from, and a project can mix a
built section with catalogue picks.

## Stated limitation

A share link does not carry it. `compressV2` encodes a section as the positional
tuple `[id, name, a, iz, {s,b,h,w,f,t,iy,j,rot}]`, which has never carried `tl`,
`profileFamily` or `composition` — an assembly shared by URL already comes back
without its make-up, today. Widening that format is a separate versioned
decision (`SHARE_VERSION`) on a file shared with the concrete work. The test
pins the current loss instead of hiding it.

## Tests

`built-section-contract.test.ts`, 17 cases: the record (all template parameters,
copied not referenced), sufficiency (feeding `built` back through
`computeSectionProperties` reproduces the stored properties, for every template),
persistence (snapshot/restore, undo, redo; the share-link loss asserted
explicitly), backward compatibility (a snapshot with the field deleted restores
clean; catalogue picks and assemblies get no fabricated record), and
visualisation (the entered lip is drawn, every template draws a real outline,
the outline survives a reload).

Also corrects this repo's own study. `m1-m2-scope-split.md` claimed a parametric
section falls to `createSectionShape`'s I-beam `default:`. It does not — every
shape tag `computeSectionProperties` emits has a case, and the test now fixes
that. The claim came from carrying PR21's generator defect over to a path where
it does not occur.

Full unit pass: 7109 passed. Typecheck: no new errors against baseline.
…eported to H1

Three documents, no code.

## `m1-section-shape-builder.md` — documented, not deleted, not mounted

`components/SectionShapeBuilder.svelte`, 500 lines, **zero importers** — static or
dynamic. The only mention outside the dictionaries is a comment in
`utils/section-drawing.ts`.

It did not fall out of a refactor by accident: `d65cd01b` EDITED it 53/51 in a
commit whose message says "Both dialogs are now free of hard-coded colour",
migrating it from the Steel/Concrete taxonomy to Thin/Solid. It survived a
refactor that treated it as a live screen. `git log -S` over the `.svelte` files
shows no commit that ever added or removed an import of it.

What it duplicates: the templates and the maths (same `SECTION_SHAPES`,
`computeSectionProperties`), and the outline preview (`crossSectionPath`, which
both mounted dialogs also use). The duplication is of interface, not engineering
— there is no parallel catalogue.

What exists ONLY there: the m ↔ cm unit toggle (`grep -c displayUnit` → 0 in both
`ProSectionsTab` and `SectionChanger`), and the text explaining why the
thin/solid distinction matters (`shapeBuilder.thinHelp`/`solidHelp`, used
nowhere else) — which is the very argument `d65cd01b` gave for the taxonomy it
introduced, sitting on the screen that cannot be opened.

What it costs: eleven keys in fourteen locales, and they are REQUIRED —
`basic-mode-coverage.test.ts` walks `components/` and demands es/en/pt answer
every literal `t('...')` it finds, so the keys are maintained because the file
exists, not because anyone sees them. Two of them (`shapeBuilder.steel`,
`shapeBuilder.concrete`) are used by nobody at all, including the orphan:
leftovers of the abandoned taxonomy, translated fourteen times.

So the product decision is not delete-or-keep. It is: does the section builder
carry a unit toggle and an explanation? Recommendation: port those two to the
mounted dialogs, then delete. The cold-formed profiles M2 would load are
published in mm, so it is worth deciding before M2, not after.

## `m2-scope.md` — six phases, each marked DATA / AUTHORITY / SHARED

Three findings that change M2's size, verified rather than assumed:

  * The seat exists. `codes/roles.ts` already declares `role: 'steel'` with
    `cirsoc301-2018` (experimental, UNSUPPORTED, `textAvailableNotImplemented`),
    and `codes/capability.ts` already enumerates the ten steel capabilities.
  * **The official CIRSOC 301-2018 text ships with the app** —
    `docs/codes/CIRSOC/markdown/cirsoc-301-2018/`, chapters A–N and eight
    appendices. Phases 2 and 4 are NOT data-blocked; they are blocked on
    implementation and on a human signature.
  * **CIRSOC 303 does not ship.** The cold-formed code that governs phase 3 is
    absent, which is why that phase is the only one blocked by DATA and
    AUTHORITY at once — and why loading C/Z tables and verifying them to 301
    would be the wrong method under a correct label.

Phase 3 also carries a precise correction: the catalogue's `C`/`MC` families are
American HOT-ROLLED channels (ASTM A6), not lipped cold-formed C. There is no
cold-formed series in the profile catalogue, while `grades/catalogue.ts` already
has a `cold-formed` family — a cold-formed steel can be declared and no
cold-formed profile exists to give it.

Phase 5 (authority) blocks nothing: phases 1–4 can be done in full with it
closed and the result stays honest. If a phase needs it open to make sense, that
phase is mis-scoped.

## `m1-h1-coordination.md` — point 5, the `built` field

File, type, commit, compatibility and the 17 tests, reported for `ae3a6186`.
Flags that `ProSectionsTab` also serves the concrete templates (it starts on
`concrete-rect`), so H1 knows the file moved: two extra fields written, no
concrete section changed.

Also reports a pre-existing loss H1 should know about and that this branch does
NOT fix: a share link carries no `tl`, no `profileFamily` and no `composition`,
so an assembly shared by URL already comes back without its make-up. Widening
`SHARE_VERSION` is a versioned decision on a shared file.
Opens M2's first block: cold-formed open sections as GEOMETRY. No strength, no
classification, no plastic modulus — see the state wiring in a later commit for
why that is a normative fact and not a gap.

## Parametric, not tabulated — and why that is the honest shape

Every other family in this catalogue is a table: `iram-c.ts`, `iram-angles.ts`,
`iram-tubes.ts` and the DIN series each name a dimensional standard, list its
rows, and treat the published area and inertias as the authority, with the
outline built to reproduce them. Cold-formed C and Z do not work that way:

  * a cold-formed section is bent from sheet, so its whole geometry is four
    numbers and ONE thickness runs through web, flange and lip alike — there is
    no independent web thickness to look up;
  * which COMBINATIONS are commercially available varies by mill and market.
    That is a commercial fact, not a normative one, and this repository has no
    sourced copy of it. **No dimension rows are shipped and none are invented.**
  * given the four numbers, the properties follow exactly.

So the identifier carries the data: `C 100x50x15x2.0` IS its specification, and
a lookup parses instead of consulting a table. A saved project therefore opens
with no catalogue behind it.

`ColdFormedSpec` has one thickness, deliberately: the generic `C-custom`
template leaves `tw`, `tf` and `tl` free, which lets a user describe a section
no mill can make. Here that is not expressible.

## The product of inertia, which the app cannot store

A C is symmetric about its horizontal axis, so its geometric axes are principal.
A Z is only POINT-symmetric: its product of inertia is nonzero, its principal
axes are rotated, and **there is no `ixy` field anywhere in this app**. So the
geometry reports `ixyMm4`, the principal pair and the rotation — not to work
around the limitation but to make it measurable. For the reference Z the product
is over half the weak-axis moment and the rotation exceeds 10°, both asserted.

`rotation` on `Section` is not a place to put it: that field is the section's
installed roll, consumed by `scene-sync.ts` offsets and `despiece-3d.ts`.

## Sharp corners, declared and quantified

A real bend has an inside radius; the radius RULE belongs to the forming
standard, and guessing `r = 2t` is exactly what `steel-profiles.ts` forbids for
root radii ("must never be guessed or back-solved from A or I"). Corners are
modelled square, and the cost is computed rather than asserted: a square corner
counts `t²` where the sharpest possible bend counts `(π/4)t²`, so four corners
of a `C 100x50x15x2.0` are 3.43 mm² of 452 — 0.76 %, always in the same
direction, so the model is never unconservative about area.

## Verified from outside itself

`cold-formed-geometry.test.ts`, 19 cases over a 200-section grid. A module that
derives its own numbers can only be trusted if the derivation is checked
externally:

  * **the C against code this module did not write** — `computeSectionProperties`'
    `C-custom` case, fed one thickness in all three slots, must reproduce area,
    both inertias and `j` exactly;
  * **the Z against the C** — turning the bottom flange around moves material
    horizontally, not vertically, so the two share `iy`, area and `j`. That
    identity caught a real bug: `partsZ` was measuring the lip from the flange's
    outer face while `partsC` measures from its mid-thickness, a `t/2`
    disagreement the module had with itself;
  * **the principal axes by rotating the tensor** — not by re-deriving the
    formula. This caught a second real defect: `atan2` spans (−90, 90], not the
    (−45, 45] the field documented, so a shallow wide Z reported 46° where −44°
    is the same pair of axes. Now normalised to the nearer principal axis.

Both invariants are asserted too (trace and determinant), because they hold for
any correct tensor and no incorrect one, independent of sign convention.

The header's own quoted area was wrong on first writing (366 mm², actually 452);
the test computes both numbers, which is how it got corrected.
The second edit to `model.svelte.ts` on this branch, taken because the
incompatibility is demonstrable rather than anticipated: `createSectionShape`
dispatches on `Section.shape`, which is an inline literal union, so a zed
without a literal there has NO outline — it renders as nothing, which in a
viewer reads as a missing member rather than an error. There is no way to draw
one without the literal, and hacking the dispatch to key on something else would
be worse than the additive change.

Two one-word additions: `Section.shape` in `model.svelte.ts:107` and
`SectionShape` in `steel-profiles.ts`. Both are unions of string literals, so
every existing value keeps its meaning and no stored model changes.

## The outline

`createZShape` mirrors `createCShape` exactly — same parameters, same meanings
(`t` is the lip LENGTH, `tl` the lip thickness), web at `x ∈ [0, tw]` with the
top flange running to `+b` and the bottom to `tw − b`. So a Z section stores
precisely what a C stores and nothing about it is new to the viewer.

Guarded like the channel is, plus one case specific to the zed: its flanges are
traversed in BOTH directions, so a flange narrower than two thicknesses crosses
the path where a channel's would not. Either degeneracy falls back to the same
section without lips rather than self-intersecting and feeding earcut garbage.

## A discrepancy in the channel that writing the zed exposed

Deciding where a lip starts turned up an inconsistency that was already there:
`computeSectionProperties` measures the lip from the flange's MID-thickness
(`(h − tf)/2 − c/2`) while `createCShape` draws it from the OUTER face. The two
models of the same channel therefore differ by `2t²` in area — 8 mm² of 452 on a
`C 100x50x15x2.0`, about 1.8 %.

Pre-existing and not introduced here. The zed follows each convention wherever
the channel does, so the discrepancy stays uniform and fixing it later is one
decision instead of two. Asserted on the CHANNEL as well as the zed, because an
inconsistency nobody wrote down is one that gets fixed in one place only.

## Tests

`cold-formed-shapes.test.ts`, 13 cases, none of which needs a rendered image:
point symmetry checked over the vertex list (`(x,y) → (tw−x, −y)` must map the
set to itself — a channel would not), area by shoelace against the closed-form
material, the `2t²` discrepancy on both shapes, every degenerate fallback still
a simple closed polygon with positive area, and the full path a viewer uses:
designation → entry → section fields → `createSectionShape`.
…s why

Completes the code half of M2's C/Z block: the selector, the boundary with the
store, and the honest answer when someone asks whether these sections are
verified.

## A lookup with no table behind it

`createColdFormedSource(series)` takes its series as a parameter — the same seam
`GradeFamilyLookup` uses. Three consequences, and the third is the reason:

  1. the shipped source is built on `NO_SOURCED_SERIES`, so there is no row list
     in the file to drift;
  2. a project library or a mill catalogue plugs in without this module learning
     about either;
  3. **the filters are exercised for real.** Query logic tested only against an
     empty series is untested logic that reports success, so the resolution test
     injects a series and drives every filter — shape, depth bounds, thickness
     bounds, text, combinations — through it. The day a sourced list lands, the
     picker already works and this file does not change.

`byId()` resolves ANY valid designation with no series consulted, and resolves it
IDENTICALLY whether or not a series contains it — asserted, because a project
must not analyse differently depending on which library happens to be open.

`list()` returns the sourced series, currently empty, and `seriesStatus()` says
`noSourcedSeries` so a picker can state something true instead of showing a blank.

## `CFC`/`CFZ`, because `C` is taken

The tabulated catalogue already has a `C` family: the American HOT-ROLLED channel
of `iram-c.ts` (IRAM-IAS U 500-509-4), a different product under a different code.
A shared id would silently merge the two in every family filter and every saved
model. Asserted from both sides — no hot-rolled channel id parses as a
designation, and neither `CFC` nor `CFZ` appears in the tabulated source's
families.

## Not `ProfileEntry`, and the vocabulary gap that is why

`GeometryFidelity`'s three values (`exact`, `nominalDimensions`, `propertiesOnly`)
all presuppose a published table to be faithful TO. There is no value meaning
"the outline is exact and the properties are derived from it", which is what a
cold-formed section is — so filing one under `exact` ("verified against published
data") would be a claim about data that does not exist. `ColdFormedEntry` is kept
structurally close so a future unification is a rename, not a redesign; what that
needs is in the limits handoff rather than guessed here.

## The model boundary writes nothing false

`coldFormedSectionFields()` converts to SI and follows the `'C'` field convention
exactly. It writes no `built` — that names a `SECTION_SHAPES` template and there
is no cold-formed one, so `shapeType: 'C-custom'` would be the same species of
lie as a fabricated `composition`; none is needed either, since the designation
IS the parameter record. No `composition`. No `ixy`, because there is nowhere to
put it.

## The state, and a deliberate deviation from the brief

The brief asked for `DEMAND_UNAVAILABLE` on any verification that does not yet
exist. `steel-status.ts` documents that state as something narrower: "the member
is metallic and the forces are not there — no solve, no combinations. Distinct
from the two above because the remedy is the user's and it is obvious."

A cold-formed member has geometry and, after a solve, forces. What it lacks is an
AUTHORITY — and that is not an implementation gap but a normative fact, quoted
from the text this app ships. CIRSOC 301-2018, chapter A:

  «Para el proyecto de elementos estructurales resistentes de: (a) chapa de acero
  doblada o conformada en frío de sección abierta y sus uniones se aplicarán las
  especificaciones del Reglamento CIRSOC 303-2009 …»

CIRSOC 303-2009 is not in `docs/codes/`. Solving changes nothing, so
`DEMAND_UNAVAILABLE` would put a true-sounding label on the wrong cause and send
a user to re-run an analysis that was never the obstacle — the defect this whole
surface exists to prevent. The state used is `NOT_DESIGNED` with a new reason,
`steel.reason.coldFormedOutOfScope`, naming the exclusion.

Placed after the authority check so `stateFor`'s stated ordering holds — most
actionable reason first. Today the branch is unreachable (nothing can bind a
metallic authority); the test binds one to prove it says the right thing on the
day something can. Recognising a cold-formed section costs a parse of the name it
already carries, so the inventory learns nothing about geometry.

es/en/pt at parity, 165 keys each.

7170 unit tests pass (+61). Typecheck: no new errors against baseline 479.
…on reports

Four documents, no code.

## `m2-cold-formed-limits.md` — the sixth part of the C/Z block

Five limits, each with the evidence and what would lift it. The two that matter:

**No verification is a NORMATIVE fact, not a missing adapter.** CIRSOC 301-2018
chapter A excludes cold-formed open sections by name and defers to CIRSOC
303-2009 — and 303 does not ship, while 301 does. Quoted rather than summarised.
Also records the deliberate deviation from the brief: `DEMAND_UNAVAILABLE` is
documented as "the forces are not there… the remedy is the user's and it is
obvious", which is the wrong cause here, so the state used is `NOT_DESIGNED`.

**A zed's product of inertia has nowhere to live.** No `ixy` exists anywhere in
the app, and `rotation` is the section's installed roll (consumed by
`scene-sync.ts` and `despiece-3d.ts`), not a place to annotate principal axes.
Stated without exaggerating in either direction: analysing a Z about its
geometric axes is wrong UNLESS the member is restrained out of plane — which is
the usual case for a sheeted purlin — but the provision defining that restraint
lives in the cold-formed code that is absent. So the honest answer is not "wrong"
and not "fine", it is "the assumption that would make it valid cannot be cited".
Lifting it needs the model and the solver, which are out of scope here.

Plus: the empty series and why an unsourced list is worse than none; square
corners quantified (0.76 % on the reference section, under 3 % across the grid,
always in the same direction); the pre-existing `2t²` disagreement between the
drawn channel and the computed one; and the `GeometryFidelity` vocabulary gap
that is why `ColdFormedEntry` is not a `ProfileEntry`.

## `share-codec-fields.md` — prepared, NOT implemented

`compressV2` writes eleven of a Section's fields and drops four: `composition`,
`profileFamily`, `tl` and `built`.

The impact is wider than the Share button. `FeedbackWidget.svelte:62` attaches an
auto-generated share link to **every feedback report** — so a user reporting a
problem with a built-up assembly sends a link that rebuilds the model without the
field the report is about. `edu/exercise-source.ts` distributes exercises the same
way. The `.ded`, undo and tab capture are unaffected: the hole is the URL codec
only, and what degrades is provenance and drawing, not the analysis.

Migration proposed as one `SHARE_VERSION = 5`, with the design constraint that
falls out of the format: the tuple is POSITIONAL, so new fields go as keys inside
the optional object (an old reader ignores unknown keys) and never as a new array
position. Ordered by bytes-per-benefit, `tl` first and `built` last since it is
the only expensive one. Names the missing test that would have caught this: a
completeness check over `interface Section`'s keys, failing when a field is
neither encoded nor explicitly declared excluded — which is how four accumulated.

Owner proposed: common integration, not either branch. The most visible loss
(`composition`) belongs to both, and all three affected features are transverse.

## Coordination

`m1-h1-coordination.md` gains point 6: the `'Z'` literal, why the incompatibility
was demonstrable (a shape with no literal reaches no `case`, returns null, and
renders as a missing member), and the one thing H1 should watch for — an
exhaustive `switch` over `SectionShape` without a `default` would now flag. None
exists today; the typecheck reports no new errors against baseline 479.

`m1-section-shape-builder.md` gains the key-by-key inventory the brief asked for:
of eleven `shapeBuilder.*` keys, three are shared with the mounted dialogs and
survive any decision, six are exclusive and in play, and two —
`shapeBuilder.steel`, `shapeBuilder.concrete` — are used by nobody at all,
including the orphan. Across fourteen dictionaries that is twenty-eight entries
nothing reads. Those two can go regardless of the product decision.
The C/Z block was a library with no surface. This gives it one, and makes the
five facts about its scope something a user actually reads rather than something
a handoff asserts.

## A selector with nothing to select from

The tabulated series ships empty and stays that way until a mill catalogue or a
dimensional standard can be cited. Normally that leaves a picker with nothing —
except a cold-formed designation IS its specification, so a section can be built
from the identifier alone. The two halves of a picker therefore come apart:
"choose from a list" is unavailable and says so; "specify a section" works
completely.

`ColdFormedPanel.svelte` is mounted inside `SteelPanel.svelte`, not as a new PRO
tab: `ProPanel.svelte` is the shared tab host that carries the concrete tabs too,
and a cold-formed section is steel, so the steel screen is where a user looks.

Four dimensions are the state and the designation is derived from them, so a
half-typed number never round-trips through a parser. Typing a designation writes
back into the four. An invalid spec names WHICH dimension is impossible rather
than printing "invalid".

## The five facts, as data

`cold-formed-scope.ts` holds them in reading order with a `kind` on each:
capability first (parametric geometry works), then the two absences (no tabulated
catalogue, CIRSOC 303 not incorporated), the exclusion (301 excludes by name),
then the conclusion (no normative verification).

Written as prose those five collapse into "cold-formed is not supported", which
is FALSE — the reader loses the capability. As data, each keeps its own kind, key
and place in the argument, the list is frozen so no consumer can reorder or
filter it, and a test can assert all five are present. `clause` is set only on
the two that are normative claims: "no sourced series" is a fact about this
repository, and dressing it as a clause would borrow authority it lacks.

## The zed warning is computed, not described

A zed's principal axes are rotated and this app has nowhere to store a product of
inertia, so the numbers shown are about axes it will not bend about unless the
member is restrained — and the provision defining that restraint is in the code
that is absent. The panel shows the ANGLE, because "rotated" and "rotated 23.4°"
are different warnings. Shown only for a zed: beside a channel, whose geometric
axes are principal, it would be a warning about nothing.

The square-corner cost is likewise computed per section, since it scales as t²/A
and one quoted figure would be right for one section and wrong for the rest.

## The zed had no 2D outline — it was drawn as a RECTANGLE

`crossSectionPath` had no `case 'Z'`, so every 2D surface that draws a section
(the stress panel's `CrossSectionDrawing`, the pickers) fell to `default:` and
drew a plain rectangle: a WRONG outline, not a missing one.

Fixed by giving the zed ONE definition and two renderers. `zedOutline` in
`profiles/cold-formed.ts` returns the vertex loop; `createZShape` turns it into a
`THREE.Shape` and the new 2D case into an SVG path. This app implements the
channel separately in three modules, and the first attempt at hand-transcribing
the zed into the second one produced a duplicated vertex and a reversed edge — a
self-crossing path. That is why it is shared rather than copied, and the 3D tests
passing unchanged after the refactor is the evidence the loop is identical.

Nothing in Basic mode can reach the new case: no `SECTION_SHAPES` template emits
shape `'Z'`, so only the cold-formed catalogue produces one.

## Tests

`cold-formed-scope.test.ts` (9) — all five present, in order, exactly one
capability, frozen, clauses only where a regulation is cited, every key resolving
in es/en/pt with the `{angle}` placeholder intact.

`zed-2d-outline.test.ts` (8) — the path parsed back into vertices, shoelaced
against the shared outline, point-symmetric about the centre, explicitly NOT the
rectangle the default branch produced, and inside the ±80 viewBox across a grid
(a zed spans 2b − tw where a channel spans b, while `sc = 80/max(h,b)` is sized
on b alone — it cannot overflow, and that is asserted rather than argued).

`m2-cold-formed-selector.spec.ts` (15 E2E) — the selector reached and used with
no series loaded, all five facts visible with the capability NOT styled as a
refusal, no verification vocabulary in any state and in any of the words the
constraint names, the zed warning present with its angle and absent on a channel,
and a specified section reaching the sections table under its designation.

Playwright ran on this worktree's derived port in the 5200–6199 band, never 4173.
7187 unit tests pass (+17). Typecheck: no new errors against baseline 479.
es/en/pt at parity, 188 keys each.
Two decisions prepared, neither implemented. Plus cross-references so the limits
document points at both.

## `m2-ixy-integration-handoff.md`

Leads with the finding that reorders the problem: **this is not a zed
requirement.** An angle is not symmetric about its geometric axes either, and the
catalogue carries **37 of them** — 27 in `iram-angles.ts` plus 10 European — all
storing geometric-axis inertias with no `ixy` field anywhere in `src/lib`.

Quantified, and validated against the catalogue's own published values before the
number was used: a two-rectangle decomposition reproduces published area within
1–3 % and Iy within 1.5–5 %, so the resulting ratio is trustworthy — **the true
minimum principal inertia of an equal-leg angle is ~40 % of what the app stores
as its weak axis.** The stored value is ~2.4× too high, on the UNSAFE side, which
is precisely why an angle strut is checked about its `v-v` axis in practice.

Scope of that claim stated explicitly: what is verified is that the angles are
selectable, that geometric inertias are stored, that no `ixy` exists, that the
biaxial stress formula is the UNCOUPLED Navier (valid only about principal axes),
and that no warning about principal axes exists anywhere. What is NOT measured is
the end-to-end error in a specific model.

Then the five things asked for: where the field would go (four layers, model
first — the easy part and not where the problem is); which consumers need it
(both solvers, both stress modules, buckling, modal, diagrams, shear flow — all
but the last on the solver side, which is out of scope by explicit constraint);
which assumptions are missing (whether the app analyses unsymmetric sections at
all; when a sheeted zed's out-of-plane restraint holds, whose provision is in the
absent CIRSOC 303; which axis governs an angle, which is normative); why it must
NOT be derived silently (it is twenty lines of arithmetic, and that is the trap —
half the catalogue has no outline to derive from, deriving would change saved
results unasked, a present `ixy` implies the analysis uses it, and a factor of
2.4 is not an incremental improvement); and the split between M3 and common
integration.

The recommended first step is the cheapest and is neither: **warn that an
unsymmetric section is being analysed about non-principal axes.** It touches no
solver, covers the 37 angles and the zed at once, and is the only item that
improves honesty without changing a number.

## `m2-lip-convention-proposal.md`

Three implementations measure the lip, two ways: `computeSectionProperties` from
the flange's mid-thickness, `createCShape` and `crossSectionPath` from its outer
face. Same `c`, `2t²` of area apart.

**The existing code recommends outer face, 2 to 1** — and two further arguments
beyond the count: a cold-formed designation names the lip as an OUTER dimension,
so under the mid-line convention a user typing 15 gets a lip `15 + t/2` deep; and
the only real lipped product the app now catalogues is the cold-formed C/Z, whose
own convention should govern.

Both alternatives costed: A (mid-line) changes both drawings and no numbers, so
no saved model moves; B (outer face) changes `computeSectionProperties` and no
drawings, so new sections lose ~1.8 % of area while stored ones keep theirs
(`snapshot` saves A and I rather than rebuilding them).

Impact on concrete templates: **numerically none.** Verified over
`SECTION_SHAPES` — `C-custom` is the ONLY template declaring `c` or `tl`. But
`section-shapes.ts` is the file that HOLDS the concrete templates, so editing it
is H1's territory even when the change does not touch a concrete line. Hence a
proposal.

The tests that move are listed per alternative, with the point that matters: the
`2t²` assertion changes meaning under BOTH options — today it says "they differ",
afterwards it must say "they do not". That is the test to invert deliberately, and
the signal the work was done.
… decided

Two proposals, no shared file touched. Both are decisions for common
integration; neither is implemented.

## `nonprincipal-axes-warning-proposal.md`

The app analyses unsymmetric sections about their GEOMETRIC axes, which are not
their principal ones, and says so nowhere. Not a zed problem — it reaches **37
catalogued angles a user can pick today**, and predates both branches.

Covers what was asked. The existing L profiles: 37 of them (10 European, 27
IRAM-IAS U 500-558), quantified with a decomposition validated against the
catalogue's OWN published values first (area within 1–3 %, Iy within 1.5–5 %), so
the conclusion is trustworthy — the true minimum principal inertia of an
equal-leg angle is ~40 % of what the app stores as its weak axis, i.e. the stored
value is ~2.4× too high, on the unsafe side.

The zed: same phenomenon, different origin, and one difference that matters for
the wording — a sheeted purlin IS restrained out of plane, which is why practice
analyses it about a geometric axis, but the provision defining when that holds is
in the absent CIRSOC 303. So the notice cannot say "wrong"; it says which
assumption would be needed and that it cannot be cited.

Why `Ixy` does not exist: the 2D solver is 3-DOF, so with one bending plane the
product of inertia appears in no equation; the 3D solver reused the same section
description (A, Iy, Iz, J), which is exactly right for the doubly-symmetric
families the catalogue originally had; angles arrived as DATA rather than as a new
kind of section, and the table publishes Iy and Iz without anyone having to decide
whether they were principal.

Geometric vs principal axes explained at the level the notice needs. Confirmed no
warning exists anywhere: `section-teaching.ts` decomposes an angle into two
rectangles and discusses its SHEAR CENTRE, never its principal axes.

That no solver number changes, guaranteed by construction: the notice is a pure
function of `Section.shape`, reads nothing the solver produces, adds no field, so
the persistence paths and the share codec are untouched. With the test that fixes
it — same model before and after, displacements and reactions equal exactly.

Where it belongs: five surfaces, ranked by where a user can still act, with the
owner of each marked. `PropertyPanel` and `ProSectionsTab` recommended first.
`SteelPanel` already does it for the zed and is the working precedent.

How not to present it as verification: the prohibitions listed (no `OutcomeBadge`,
no `--st-ok`, no ratio, none of the claim words, no census, no certificate) and
what it actually is — a note about how the app REPRESENTS the section, not about
whether the member passes.

Presence AND absence tests, as a table over every `SectionShape` literal so that
adding a shape breaks the test rather than defaulting to silence: warn on L, invL
and Z; stay quiet on I, H, U, C, T, RHS, rect, CHS. Plus the declared blind spot:
`generic` is properties-only with no outline, so the app cannot know — and
asserting symmetry it cannot verify would be worse than silence.

## `m2-lip-convention-proposal.md` — outer face adopted, with decisive evidence

Upgraded from an open question to a recommendation, on evidence stronger than the
2-to-1 implementation count.

**Convention B reproduces the drawn outline exactly.** Polygon moments of the
loop `createCShape` actually walks, against what each convention computes, over
four sizes: B matches area, Iy AND Iz to machine precision in every case, while
today's convention is off by exactly 2t² in area and its reflection in both
inertias. So B is the convention under which the calculation and the drawing
describe the same object, and there is no third option that also is.

**A second inconsistency, found while writing the patch, worse than a t/2 shift.**
The guards disagree: `createCShape` draws a PLAIN channel when `lip <= tf`, while
`computeSectionProperties` happily computes `2·c·tl` of lip in that range. For
`c ≤ tf` the app therefore counts material that the outline omits entirely. B
closes it with no new guard, because the useful lip is `c − tf`, which is ≤ 0
exactly when the drawing refuses to draw one.

Then the four things asked for: the proposed patch as a diff (verified against
those polygon moments, with the two sub-decisions it exposes named rather than
taken by omission — the validity bound loosens from `c + tf > h/2` to `c > h/2`,
and `c ≤ tf` becomes "plain channel" instead of an error); the tests that flip
from "differ" to "coincide", with the `2t²` describe to be inverted and renamed
deliberately plus a new polygon-moments-equal-properties test so the unification
cannot be undone quietly; the impact on C and Z (identical, and they must move
together or the `iy(Z) == iy(C)` identity that validates the whole zed derivation
breaks); the notice for H1 (numerically zero — `C-custom` is verifiably the only
template declaring `c` or `tl` — but `section-shapes.ts` holds the concrete
templates, so it is their territory); and a five-item single-commit procedure with
one decisive acceptance criterion.
# Conflicts:
#	web/src/components/pro/ProConnectionsTab.svelte
#	web/src/components/pro/generators/ProfileSelectorPanel.svelte
#	web/src/lib/i18n/locales/pt.ts
@Batuis
Batuis changed the base branch from feat/pro-steel-family to main August 26, 2026 02:03
Batuis added a commit that referenced this pull request Aug 26, 2026
Part II of the integrated audit. Records the divergence before touching anything — including that
`feat/pro-steel-family`, PR #156's base, was already contained in main, which is why retargeting
M1 to main does not grow its diff — the three conflicts merging main into M1 and the two merging
M1 into M2, each with its resolution and why.

Classifies the seven M1 failures the merge surfaced: four obsolete tests that pinned defects main
has since fixed, one documentation item the test itself predicted would come due, one product
decision left open, and one that was a stale local WASM artefact rather than any branch's fault —
which invalidated earlier local numbers, so every figure here is from after the rebuild.

The merge into M2 dropped a declaration and left three references to it; the panel threw at mount
and nineteen E2E failures were that one error. Neither the build nor 7970 unit tests saw it.
Batuis added a commit that referenced this pull request Aug 26, 2026
…t open

Part I audits the CI of #156 and #164 and explains every absent or red check rather than reporting
a colour. The two findings that change what a green means:

  - **no `concurrency` block.** M1 has a green run AND a red run on the same sha, one second
    apart, and `gh pr checks` shows one row per check name — so the red is invisible there and
    only appears in the rollup. The difference between the two runs is exactly one test,
    `basic-demos:285`, which neither branch touches: flakiness, evidenced rather than assumed.
  - **the repository's ruleset declares no required status check at all.** #156 reads BLOCKED
    because `main` requires one approving review and it has none. CI on this repository is
    advisory; the missing approval is the only thing stopping a merge.

Also recorded: the `@slow` suite and the visual baselines never ran on either PR — both are gated
on a `run-e2e` label neither has — there is no typecheck or `svelte-check` job in CI at all
although both scripts exist, and `@perf` and `basic-selection-permutations` are covered by
nothing.

Part II records the three decisions and, explicitly, the two places the implementation diverges
from `m1-m2-open-findings-proposals.md`: the inline BUILDER was removed as well as the catalogue,
and the auxiliary calculator got a vocabulary of its own rather than the metallic status badge.
Both are argued where a reader can disagree with them.

Part III is what is not closed. The one worth the space: `.conn-ratio-badge` is CSS that `main`
deleted in a commit titled "no green tick for steel", which the union resolution of M1's merge
brought back — and which no template applies on any ref, so M1 spent a commit fixing the contrast
of rules the browser never paints and a test pins them by reading the CSS text. That is a product
question in both directions, so it stays open. Beside it, measured: `svelte-check` reports nothing
at all for `ProConnectionsTab.svelte`, proved by adding a deliberately unused selector and getting
zero mentions against nine for `ProVerificationTab` in the same run.
Batuis added a commit that referenced this pull request Aug 27, 2026
`gh pr checks 156` reported all seven green and exited 0. The GitHub UI showed the PR as not
passing. Both were true, and the gap between them is the finding.

M1's head sha carries **fourteen** check runs, not seven, because two github-actions check suites
exist on it. Exactly one run was red — `e2e` from `32921289231` — and `gh pr checks` prints one row
per check name showing the latest, so it never appeared there. M2's head has a single suite, which
is the whole difference between the two PRs: not the code, the number of suites.

The duplicate is dated by the PR timeline. `base_ref_changed` at 02:03:28 — #156 retargeted from
`feat/pro-steel-family` to `main` — and two `pull_request` runs created at 02:03:30 and 02:03:31,
same head sha, same actor, `run_attempt=1` each. The base change was legitimate; what turned it
into two full 28-minute runs is that `ci.yml` has no `concurrency` block, so neither cancelled the
other and one of them hit the flake.

A third suite, from the `render` app, sits `queued` with zero check runs and keeps the legacy
combined status pending. It is identical on M1, M2 **and main**, so it explains nothing — recorded
so nobody chases it.

Reproduced locally on a worktree of M1's exact sha: `basic-demos:285` passes 3/3 in the repeated
file, and the full `--grep @smoke` suite — precisely what CI runs — comes out 336 passed, 0 failed,
1 flaky, 4 skipped, the same shape as CI's green run. It does not reproduce on darwin. The
mechanism is in the timings: CI takes 25.4 minutes for what takes 10.5 locally, and the test throws
four synthetic canvas clicks hoping one lands on a member.

The asymmetry that lets this test go red at all: `:160` declares
`test.describe.configure({ retries: 2 })` for exactly this reason, and `:285` — structurally the
same canvas-click walkthrough — has none. Deliberately not fixed here: `basic-demos.spec.ts` is
byte-identical between M1 and main, and changing it from a steel branch would give M1 ownership of
a Basic-mode spec it has no reason to touch.

CI was rerun on the same sha with nothing changed: attempt 2 of `32921289231` is green, all
fourteen check runs are now success or skipped, both suites conclude success, and #156 stopped
showing red without a line of code moving. That is the fourth demonstration of this suite producing
both colours on identical input.

`concurrency` was NOT added to `ci.yml`, and the reasoning is in §30 along with the patch that
would fix it: the workflow is main's infrastructure, and `cancel-in-progress` trades a red for a
cancelled run unless the group is chosen deliberately. Nothing was committed to M1 either — any
commit moves it off the sha that just went green.
Batuis added 7 commits August 26, 2026 22:19
… since b71432c

`.conn-ratio-badge` is dead CSS on this branch, and the census says so in every
direction: `class="conn-ratio-badge` appears 0 times in `origin/main`, 0 times in
this branch before the merge with main, 0 times in the merge, and 0 times now.

It was not main that orphaned it. `b71432cd` — this branch's own commit, the one
that split *Metallic joints* into four `StageSection` sub-sections — moved the
governing demand/capacity ratio to the shell's neutral `badge` prop and deleted
the two `<span class="conn-ratio-badge {statusClass(...)}">` that were its only
consumers. `statusClass` went with them. Two days later `851fd57b` measured the
contrast of the three state rules and rewrote all three, and
`steel-surface-colour-rules.test.ts` pinned the result in five assertions: a
commit and a test suite about CSS the browser has never painted. `main` reached
the same conclusion independently and deleted the block in `2c79ed52` ("no green
tick for steel"); resolving `ProConnectionsTab.svelte` by keeping both sides in
`9883e2bd` is how it came back.

Nothing flagged it because nothing could. Svelte prunes unused selectors and
warns, but it stops as soon as a component has a class attribute it cannot read
statically, and this one has four (`class="conn-result-card {auxTone(...)}"`).
Measured by appending a deliberately dead selector to every styled component and
compiling: 30 of 169 are blind the same way, 24 of them under `components/pro/`.
`svelte-check` reports what the compiler reports, so its silence on this file was
accurate rather than broken.

── What replaces the five assertions ──────────────────────────────

Not nothing, because the compiler will not write this guard:

- the stylesheet contains no `conn-ratio-badge`, so a union merge cannot revive
  it unnoticed a second time;
- both sub-sections still hand `StageSection` the percentage, so the number the
  badges carried is still on screen;
- no element on the panel carries `st-ok` / `st-warn` / `st-fail`, so a ratio
  gets no status hue at all — the auxiliary block's own vocabulary
  (`within / near the limit / over the limit`) stays the only thing entitled to
  comment on it.

The `--st-accent`-on-a-red-tint rule keeps its empty list: the shape of the
defect outlives the one rule that had it. The check that selection may still use
`--st-accent` is carried over verbatim.

No template, no component logic and no token changes. `npm run test` (unit):
7401 passed, 12 skipped, 1 todo.
…rings that were never there

The removal's third assertion asked that no class attribute on the panel carries
`st-ok`, `st-warn` or `st-fail` written out. That is true, and it was true on the
day the badges were on screen too: the spans read
`class="conn-ratio-badge {statusClass(...)}"` and `statusClass` was
``return `st-${s}` ``, so the three strings never appeared in the file. A guard
that its own defect would have passed is not a guard.

It now asserts the shape instead — no helper on this panel turns a status into a
class name — and keeps the written-out check beside it, which covers the other
route. Verified against `b71432cd^`: the new assertion fails there, the old one
passes.

Worth recording because it is the same fact twice. The interpolation that hid the
class name from a grep is the interpolation that stopped Svelte pruning the
rules, which is why the CSS outlived its consumers for a week without a warning.
…anch

`66259cee` replaced the removal's third assertion with a ban on turning a status
into a class name. CI found the problem immediately, and it was right to: on this
branch `statusClass` is still applied to `.conn-result-card` and
`.conn-status-icon` — the pre-M2 auxiliary verdict, green ✓ in `--st-ok`
included. `8e538631` is what retires that, on M2, where
`steel-never-verified` (with the `ProConnectionsTab` exemption removed) and
`metallic-joints.spec.ts` guard it. Removing dead badge CSS is not the commit
entitled to legislate it, and a test both branches run cannot assert a property
only one of them has.

What survives, because it is the claim the removal actually makes: the class name
appears nowhere in the component — stylesheet and class attributes alike, which
are the two ways it comes back — and both sub-sections still hand `StageSection`
the percentage.

The reasoning for what is deliberately NOT asserted is kept in the block comment,
including the second thing the banned assertion got wrong: `statusClass` was
``return `st-${s}` ``, so `st-ok`, `st-warn` and `st-fail` were never in the file
and a grep for them called this component clean on the day the badges were on
screen. The interpolation that hid the class name from a grep is the interpolation
that stopped the compiler pruning the rules. One defect, two symptoms.

`npm run test` (unit) on this branch: 7400 passed, 12 skipped, 1 todo.
… publish

`armKinds` and two helpers in `basic-selection.spec.ts` read `aria-checked` off the
selection-kind toggles. `SelectionPanel` renders plain `<button aria-pressed>` and not
radios-that-become-checkboxes — a deliberate choice the component documents, because a
radiogroup owes a roving tabindex and arrow-key movement that the list never implemented.

On a button, `aria-checked` is always `null`. So the two reads in `armKinds` inverted each
other: the additions loop saw `null`, clicked, and armed a kind; the removals loop saw `null`
again on the very same kind and clicked it straight back off. The audit failed on the first
two-kind permutation — `elements+nodes` — in both viewports, and reported `["elements"]` where
it wanted `["elements","nodes"]`.

It had failed that way from the start. This file carries no `@smoke` tag and CI's `e2e` job
runs `npx playwright test --grep @smoke`, so the fifteen-permutation audit has never run in
any PR. It surfaced now because the whole local suite was run rather than the smoke subset.

The two sites in `basic-selection.spec.ts` were passing, and were passing by luck: they arm
kinds that happen to start unarmed, so a click on a `null` read was the right click. They move
to `aria-pressed` too, because the next person to reorder those tests should not have to
rediscover this.

No product code changed, and no assertion was weakened: the audit now genuinely walks all
fifteen subsets in both viewports — 5.5 s and 7.1 s against the 1.3 s and 3.8 s it took to
bail out on the third one.
…ts its own budget

`stays free along the building with wall bracing alone` looped over two parameter sets, so one
`it` did two shed generations and two full solves — the heaviest pair in the file — inside the
5 s default timeout. 1,65 s on the machine it was written on, and over 5 s on a CI runner inside
the 8100-test pool of M2, where it timed out and took the `web` job with it.

Split into one test per configuration. Each does one solve: 761 ms and 765 ms, about 15 % of the
budget instead of 33 %. Same two assertions on each, no timeout declared, no case dropped — and
the failure now names WHICH configuration failed, where the loop reported the timeout against the
`it` and left both candidates equally plausible.

Measured while looking: `returns a mechanism wearing a number under a load along the building` is
2,89 s, the slowest in the file by a wide margin, and there is nothing to split — it is a single
solve, and it is slow because the unbraced shed is near-singular by design, which is the whole
point of the assertion. Shrinking the model would change what it measures. Left alone and written
down here instead.
…through too

`the section walkthrough › advances when the reader clicks the member` captured the canvas box
once and then aimed four clicks at it. A click that lands on a member opens the property panel,
and that reflows the canvas — so attempts 2 to 4 went to where the canvas used to be, the step
never left `pick`, and the failure reported only that `sliders` never arrived.

This is the same defect the walkthrough test above it already fixed, and it carries the reason in
its own comment: "Measured before each gesture, not once: the walkthrough starts with the Project
panel open and frames the model as it goes, so a box captured at the first step sends later clicks
to where the canvas used to be." This test kept the single measurement.

It failed on CI in the `e2e` job of BOTH #156 and #164, in the same window, and the only delta
from the green run before it was a vitest file Playwright does not read. Fixed rather than retried:
three consecutive local runs of the spec pass in 9.3 s, 9.5 s and 10.1 s.

No retry was added and no timeout was raised. The 15 s poll on `sliders` is untouched — it was
never the problem, it was where the four missed clicks became visible.
@Batuis
Batuis marked this pull request as ready for review August 27, 2026 19:51
Batuis added a commit that referenced this pull request Sep 4, 2026
Built from the REAL diff of the three PRs against their bases, not from the earlier handoffs, and
with every claim labelled by how well it is known.

## How it was built

The three PRs are a stack, so each diff is the span between its base and its head:

  #156 M1   b579de8..m1    56 files  +9,937 / -52     26 production files
  #164 M2   m1..m2         135 files  +24,058 / -1,242 73 production files
  #183 M3   m2..m3          15 files  +1,672 / -47     11 production files

Every statement carries a marker: [V] verified against the current code in this session, [M]
measured by running something, [H] inherited from a handoff and NOT re-verified. [H] is a lead, not
a fact — and where a decision would rest on one, the inventory says to verify it first.

## What it covers

The 24 requested blocks, in A (mandatory QA, 48 entries with PR of origin, file, visible name, exact
route from 4005, preconditions, steps, expected result, alternative and empty states, persistence,
stage interaction, a11y, performance, normative limits, testids and hooks, severity, and M3-or-
accepted) · B (recommended) · C (blocking) · D (fit for M3) · E (accepted limitations) · F (code
with no user path) · G (a manual checklist in three passes) · H (how future findings enter M3).

## Two inherited claims re-verified rather than repeated

Both came from the M2 inventory, both are exactly the kind of claim that ages badly, and both were
checked against the code and CONFIRMED:

  - `SectionShapeBuilder.svelte` is never mounted: `<SectionShapeBuilder` has zero hits, and the 14
    references to the name are 13 i18n files plus one comment;
  - `notVerifiable` for the BOLT GROUP cannot be produced from the UI. It needs threads='excluded'
    or an untabulated diameter; the panel writes `threads: 'included'` as a fixed literal and the
    diameter select offers only tabulated values. The weld DOES reach `notVerifiable` (§J.4 base
    metal), so the state is not dead everywhere — only in the bolts.

## Two corrections to the brief, stated rather than silently absorbed

  - the suite uses FIVE viewport sizes, not four: 390x780, 390x844, 800x720, 1280x720, 1280x800.
    1440 appears nowhere in a spec — only as a CSS value;
  - `joint.scene.notDesigned` had no translation in any locale until M3, because nothing had ever
    rendered it.

## Blocking findings: none open

The three that existed during the work are recorded as closed, including one that was mine — a false
diagnosis of the walkthrough flake, published and then retracted. The criterion is stated so the
classification can be argued with: blocking means a correctness error, data loss, a false claim or a
broken main flow. UX and debt do not qualify, however severe they look.

No correction was implemented, not even the one-line CSS one. No solver, Rust, Cargo or WASM change.
No snapshot and no timeout touched. M1 and M2 were not modified.
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