M2 — PRO steel: section and material selectors, generators, the metallic workflow, and joint design - #164
Open
Batuis wants to merge 69 commits into
Open
M2 — PRO steel: section and material selectors, generators, the metallic workflow, and joint design#164Batuis wants to merge 69 commits into
Batuis wants to merge 69 commits into
Conversation
…f two
M2's first block. The rule is implemented and tested; **no shared consumer is
touched**, because it turns out none of the surfaces where the notice belongs is
M2's to edit.
## The rule
`lib/section/axes.ts` — pure, over `Section.shape` and nothing else.
`axesSymmetryOf` returns `principal` / `notPrincipal` / `unknown`;
`warnsAboutAxes` is true only for the middle one; `axesNoticeKeyFor` names the
sentence.
`notPrincipal` for `L`, `invL`, `Z`. `principal` for `I`, `H`, `U`, `C`, `T`,
`RHS`, `CHS`, `rect` — one axis of symmetry is enough, so a channel and a tee are
as principal as an I-beam. `unknown` for `generic` and for a section with no
shape: properties-only, no outline, so its symmetry is not a fact the app has —
and **not warning is as important as not claiming symmetry**, which is why
`unknown` is its own value rather than folded into `principal`.
**Shape alone decides it, and that is exact rather than approximate.** Symmetry
here is topological: an angle has no axis of symmetry whatever its legs measure,
a zed has point symmetry at every size. No shape in this app is symmetric for
some dimensions and not others.
**Exhaustiveness is a compile error.** The table is a `Record<Shape, AxesSymmetry>`,
so adding a literal to `Section['shape']` without classifying it stops the module
compiling. The test repeats it at runtime for a reader who only runs the suite,
checking against a second opinion written by hand rather than imported.
## One rule, not two — a duplication removed
`ColdFormedPanel` was already warning, on its own predicate (`shape === 'Z'`) and
its own sentence (`COLD_FORMED_ZED_AXES_KEY`). Both are gone; the panel now calls
the shared rule like any other surface would.
That was not tidying. The same predicate has to cover the **37 catalogued
angles**, and two surfaces warning on two different rules teaches a reader that
the app is inconsistent rather than that their section is unsymmetric.
`cold-formed-scope.test.ts` now asserts the scope module exports no axes key at
all, so the duplicate cannot come back quietly.
What the cold-formed panel keeps is the one thing no general surface can supply:
the **measured angle**. It holds the geometry, so it says "rotated 23.4°" where
`SectionEditor` can only say "rotated".
## Two sentences, because the two cases differ
An angle has no escape: its axes are rotated and that is that. A sheeted purlin
usually does — it really bends about close to a geometric axis — but the
provision saying when that restraint counts is in CIRSOC 303, which this app does
not carry. So the zed's text says the assumption cannot be cited, and cites 303;
the angle's does not, because pointing at 303 there would suggest a way out that
does not exist. Asserted per language.
Keys are named `section.axes.*` — neutral, correct for their eventual home — and
live in the steel dictionary, which is M2's. `store.svelte.ts` flattens
`{ ...es, ...steelEs }`, so a `section.*` key resolves identically from there and
the shared general dictionary is left alone. Moving the block later is a
cut-paste with no key rename.
## It changes no number, and that is proved twice
Structurally: the rule is handed `Section.shape` and nothing else, so there is
nothing else it could influence — checked with a section stripped to just its
shape, and with inertias differing by orders of magnitude.
Empirically: a cantilever on an `'L'` section still deflects PL³/3EI **on the
stored inertia**, because the notice does not correct the number. That is the
honest position — the app says the axes are not principal and keeps analysing
about them, rather than silently substituting a value nobody sourced. And the same
cantilever with `shape` set to `I`, `L`, `invL`, `Z` and `generic` gives
bit-identical displacements AND reactions. That second test is what would catch a
future implementation feeding the predicate back into a resolver.
## Why nothing shared was edited — and a correction to my own proposal
`nonprincipal-axes-warning-proposal.md` recommended starting with
`PropertyPanel.svelte`. **Wrong, and found while implementing:** that file shows
no inertias at all (zero occurrences of `.iz`), and `property/SectionDetails.svelte`,
which looked like the section's own card, is an **empty 8-line placeholder with no
markup**.
The real surfaces are `SectionEditor.svelte` (the global modal where `A` and `Iz`
are read and edited — every section passes through it, concrete included),
`ProfileSelector.svelte`, `ProSectionsTab.svelte` (starts on `concrete-rect`), and
`SectionStressPanel.svelte`. That last one is the most consequential, because it
displays the uncoupled biaxial Navier that is only valid about principal axes — and
it is mounted from `ribbon/BasicPanel.svelte`, i.e. Basic mode, which this branch
is forbidden to touch.
So: shared with concrete, shared with concrete, shared with concrete, and Basic.
**Zero surfaces available to M2**, which is why this commit carries a contract
document instead of those edits. `m2-axes-notice-contract.md` has the per-file
diffs ready for whoever owns each, the prohibitions that keep the notice from
reading as a verification, and the four things H1 needs to decide.
7209 unit tests pass (+22). 15 E2E pass on this worktree's derived port.
Typecheck: no new errors against baseline 479. es/en/pt at parity, 191 keys.
…handoff Two documents. No code, and none of the four forbidden consumers touched. ## `m2-lip-convention-validation.md` — H1's patch measured, not trusted `120f15cc` applies the proposal's patch verbatim. Validated with an independent Green's-theorem integration over the outline `createCShape` actually walks, against H1's formulas transcribed from the diff — nothing imported from H1's own test. **Verdict: no divergence. The patch is not modified.** Four checks: 1. **Drawn polygon vs computed A, Iy, Iz** — worst relative error 1.62e-16 over six sizes. The calculation and the drawing describe the same object, which was the stated acceptance criterion. 2. **`c <= tf`** — computed rather than rejected, ΔA against the drawn (unlipped) outline exactly 0, and identical to the `c === tf` case, so it really IS the plain channel. Six cases across two sections. 3. **`c > h/2`** — rejected at 50.1 and 51 on a 100 section, accepted at 49 and at exactly 50. The bound genuinely loosened: with `tf = 2` the old `c + tf > h/2` would have rejected `c = 49`. 4. **C and Z under one convention** — under outer face the mirror brings M2's C to 1.77e-16 of H1's, and the `Iy(Z) == Iy(C)` identity still holds. Under mid-line, which is what M2 has today, that identity also holds but M2's C sits **5.58e-2** from H1's. The cross-branch divergence H1 reported, quantified. **Why the mirror was NOT applied, which is arithmetic and not caution.** `section-shapes.ts` in M2's tree is still the old version — `120f15cc` lives only on H1's branch and M2 does not integrate H1 (verified: `grep -c "Math.max(0, c - tf)"` returns 0). `cold-formed-geometry.test.ts` asserts that `cold-formed.ts`'s C reproduces `computeSectionProperties` exactly, and that assertion is TRUE and is what pins the whole cold-formed derivation against code M2 did not write. Applying the mirror alone would break it by exactly 2t² — 8, 12.5, 18 and 4.5 mm² on the four grid sizes, measured. So M2's current state is the correct one: C and Z consistent with each other, both on mid-line, and the difference from H1 measured and reported. The mirror goes in the same commit that brings `120f15cc` into the tree, together with `validateColdFormed`'s loosened bound and the tests that flip from "differ" to "coincide". Atomic, and this document carries the four measurements that accept it. **Also corrects my own proposal.** §6 claimed `zed-2d-outline.test.ts` stays intact. False, and the measurement is what showed it: `zedOutline` ALREADY measures the lip from the outer face, so that test does not fail because it is unaffected — it does not fail because BOTH of its sides move together. Same for `section-profiles.test.ts`. Corrected here rather than by rewriting a document H1 has already cited as the source of the decision. ## `m2-axes-notice-contract.md` — now the complete integration handoff Absorbed the remaining points rather than opening a second document, since two documents describing one notice is the same duplication this work removed from the code. Added a specified-behaviour section that a consumer can rely on without reading the module: the three shapes that warn (`L` and `invL` sharing a text, `Z` with its own, and why that is not a wording detail — an angle has no escape while a sheeted zed does, and citing CIRSOC 303 on an angle would suggest a way out that does not exist); the eight that do not, with one axis of symmetry being enough, so a channel and a tee are as principal as an I-beam and a notice there would be noise that trains users to ignore it; and `generic`, where both halves matter — do not warn, because that would be a guess, and do not claim symmetry either, because that is the opposite guess and worse, since a later consumer could read `principal` as permission. Plus the six sentences verbatim in es/en/pt, the fact all three must carry (that the app cannot store a product of inertia — the thing that makes the notice actionable instead of alarming), and why the keys are neutral `section.axes.*` names living in the steel dictionary: file ownership, not content, so relocating them is a cut-paste with no key rename.
…gs already did
The application measured a lip two ways. `computeSectionProperties`'s `C-custom`
used the flange MID-LINE — `(h - tf)/2 - c/2` — while `createCShape` walks from
`-halfH` and `crossSectionPath`'s `'C'` from `-hh + lip`. With the same `c` the
calculation counted `2t²` more material than either drawing: 452 mm² against 444 on
a `C 100x50x15x2`, about 1.8 %.
Convention decided by M1 in `docs/handoffs/m2-lip-convention-proposal.md`, and
applied here because `section-shapes.ts` also holds the concrete templates, so it is
not M1's to change unilaterally. The patch is theirs, verbatim.
── Verified against the polygon, not against a table ──────────────
`cold-formed-lip-convention.test.ts` integrates the outline `createCShape` actually
walks — Green's theorem over its vertices — and compares A, Iy and Iz against what
`computeSectionProperties` returns for the same parameters, on the four sizes from
the proposal. They agree to **1e-9 relative** on the inertias and to 1e-12 absolute
on the area.
A table of expected numbers would pin four sections. This states the property: the
two halves describe the same object. Confirmed to be a real test by reverting the
convention and re-running — it fails by exactly 8, 12.5, 18 and 4.5 mm², which is
`2t²` for t = 2, 2.5, 3 and 1.5 mm. M1's table reproduced independently.
── The regime that was worse than a t/2 shift ─────────────────────
`createCShape` renders an UNLIPPED channel when `lip <= tf`, and the calculation
added `2·c·tl` of lip regardless. For `c <= tf` the app computed a section with a lip
and drew one without — material that existed in the numbers and not in the outline.
`Math.max(0, c - tf)` closes it by construction, with no new guard: the useful lip is
≤ 0 exactly when the drawing refuses to draw one. Tested three ways — `c === tf`
matching the drawn outline, `c < tf` computing rather than being rejected, and the
result equalling a `U-custom` of the same plate, which is the strongest way to say
"no lip means it IS the channel".
The validity bound loosens with it: lips collide at `c > h/2`, not `c + tf > h/2`.
The second sub-decision the patch exposes, taken on purpose and tested at 0.049,
0.050 and 0.0501 on a 0.100 section.
── Z: not in this tree, and it matters ────────────────────────────
The brief says C and Z must move together. **In H1's tree there is no Z at all** —
no calculation in `section-shapes.ts` and no drawing case; the `'Z'` occurrences in
`section-drawing.ts` are the SVG closepath command, which is what my first grep
found and misread.
Z lives in M1's `lib/profiles/cold-formed.ts` (`partsC` / `partsZ`), added in
`01da50cb` and `8f80481e` and NOT merged here. Its mirror is two lines and is spelled
out in §4 of the proposal:
vLip = (h - c - t) / 2 and ht: c - t on both lip parts
So until those branches integrate, **H1's C follows the outer face and M1's C and Z
still follow the mid-line.** That is a real inconsistency across the two branches and
not something this commit can close from here. `validateColdFormed`'s `lipsCollide`
has to follow the loosened bound in the same integration.
Reported to M2 for validation.
Gates: unit 375 files / 7022 tests — nothing depended on the old values · typecheck
479 against baseline 479 · production build clean · the nine new assertions verified
to fail on the old convention.
… 2t² gap
The second half of the atomic integration. `04019c97` cherry-picked H1's
`120f15cc` — the calculation moved to the outer face — and this brings
`profiles/cold-formed.ts` with it, which is what makes the tree consistent again.
Authorised as a single-commit cherry-pick, no merge of H1's branch and no rebase.
Backup at `backup/m2-pre-lip-integration`. The cherry-pick touched exactly the two
expected files (`section-shapes.ts` and its new test) and nothing outside the C/Z
change.
## The intermediate breakage, observed rather than argued
Between the two commits the tree was inconsistent, and deliberately so: with
`section-shapes.ts` on the outer face and `cold-formed.ts` still on the mid-line,
`cold-formed-geometry.test.ts` failed by exactly **2t²** — 4.5 mm² at t = 1.5.
That is the invariant the validation document predicted would break if the mirror
were applied alone, now shown from the other side.
## The mirror
`partsC` and `partsZ` both take `cl = Math.max(0, c − t)` for the lip that adds
material and `vLip = (h − c − t)/2` for its centre. Two shapes, one substitution,
same commit — they had to move together or `iy(Z) == iy(C)` would have broken, and
that identity is the independent check on the entire zed derivation.
`validateColdFormed`'s `lipsCollide` follows: lips collide at `c > h/2`, not at
`c + t > h/2`. Looser by exactly `t`, which is the second sub-decision the
convention exposes. And `c <= t` is no longer rejected — it means the lip adds
nothing beyond the flange, i.e. a plain channel, which is what both drawings have
always rendered.
## A bug of my own that the tests caught
`jMm4` still used `c` where the areas had moved to `c − t`, so the torsion
constant was off by `2(c − cl)t³/3` — 3.375 mm⁴ on one grid size. Found by the
test that pins this module against `computeSectionProperties`, which is exactly
what that test is for: it disagreed on `j` alone after area, Iy and Iz had come
into line.
## The tests, inverted on purpose
`cold-formed-shapes.test.ts` used to assert `computed − drawn === 2t²` for the
channel AND the zed. Both now assert `0`, and the describe is renamed — a test
whose title still promised a discrepancy would protect nothing.
Added in the same commit, as the proposal required: **polygon moments against
computed properties**, by Green's theorem over the vertex loop each renderer
emits, for C and Z across a grid, at 1e-12 relative. Area alone would pass for a
lip in the wrong place; this is what makes reverting the unification loud.
Also new: `c <= t` asserted as a plain channel in both the geometry and the
outline, and the loosened bound asserted at both ends (49 and 50 legal, 51 not).
## The five verifications the procedure asked for
* **A, Iy, Iz** — polygon vs computed, 1e-12 relative, C and Z, four sizes.
* **`c <= tf`** — computed not rejected, equal to the no-lip section, and equal
to the drawn outline.
* **`c > h/2`** — rejected at 51, accepted at 49 and at exactly 50.
* **C and Z on one convention** — `iy(Z) == iy(C)` still holds, and each shape
agrees with its own outline.
* **Machine precision** — the acceptance criterion, as a permanent test rather
than a one-off measurement.
7221 unit tests pass (+12). 15 E2E pass. Build pass clean. Typecheck: no new
errors against baseline 479.
…t required Investigation only. Five phases (A–E) preceded by the audit that was the precondition for touching anything. ## The audit, which governs everything else `connection-design.ts` is 307 lines: two capacity checks (`checkBoltGroup` per CIRSOC 301 J3, `checkFilletWeld` per J2), their two tables from Tablas J.3.2 and J.2.4, plus `detectJoints` (topology) and `getJointForces`. **There is no connection geometry anywhere.** No plates, no bolt positions, no pitches, no gauges, no weld runs, no connection-material thicknesses. `plateThickness` enters as a SCALAR for bearing and for the fillet size limit, not as a plate. The module's only coordinates are the NODE's `x, y, z` in `JointInfo` — that is connectivity, not detail. And there is no connection entity in the model either. `model.connectors` exists but `ConnectorElement` carries only stiffnesses (`kAxial`, `kShear`, `kMoment`, `kShearZ`, `kBendY`, `kBendZ`) — its own comment says sliders, bearings, isolators, joint flexibility. A solver primitive, not a bolted joint. Confirms the brief's suspicion exactly: bolt groups and welds are covered as capacities from typed scalars, which does not imply detailed geometry or complete normative verification. Builds on M1's five already-declared `conn.gap.*` gaps rather than redoing them — the relevant one being `boltGeometry`, where the group layout is missing and the reported capacity is explicitly "un techo". ## M2-A: the spheres, measured `nodes-instanced.ts:12` — `DEFAULT_RADIUS = 0.07`, a fixed radius in METRES, and `Viewport3D.svelte:46` constructs with no options, so it never scales. 140 mm across, whatever the model. Against the real catalogue of 99 profiles: **23 are shorter than the sphere**, **60 % have the sphere covering more than half their depth**, the median (IPE 240) sits at 58 %, and the worst case `L 30x30x3` has a sphere **4.7× its depth**. Which is why it only shows in sections mode — in wireframe a 140 mm sphere reads as a joint marker, with real extrusions it swallows them. Angles are worst, and angles are truss members, where nodes cluster. The four alternatives assessed against one fact: three of them break node picking, because three.js excludes invisible objects from raycasting and `NodesInstanced` is the only node pick target. The hiding infrastructure already exists (`lod.ts:77`) but is used only by the heavy-model orbit fallback — a transient state where nobody is clicking. Recommends alternative 3, contextual gizmo, and not out of caution: it is the only one that breaks nothing and the only one that addresses the measured cause, which is not that nodes exist but that their size bears no relation to the model. Screen-space radius preferred, since `Line2`/`LineMaterial` already do exactly that for members. Names what must be verified first — picking hit rate (a 4 px marker is a 4 px target), isolation, and accessibility, where the fix is a picking radius larger than the drawing radius, the pattern `create-element-mesh.ts:242` already uses. ## M2-B: the inventory, eleven elements × seven questions Three levels exist, and only the first is complete: topology (solid), capacity (two checks with five declared gaps), geometry (**nothing at all**). The one element with real geometry is the back-to-back composite, and only because `composition` describes it — while the batten plates that hold the `gapMm` apart do not exist. The model declares a separation sustained by nothing. Also flags a vocabulary problem: `DEMAND_UNAVAILABLE` is documented as "the forces are not there", but here the forces may well be present and what is missing is the GEOMETRIC datum. Needs its own state or a specific reason, the way M2 already did for cold-formed. Common-integration decision, since it touches the state vocabulary. ## M2-C: the detail viewer contract Read `rebar-scene.ts` (1319 lines) and extracted the six patterns worth copying, each with its stated reason — including the one that transfers exactly: markers get their own `pickableConflicts()` because small things inside a cage would "take clicks away from the bars", which is precisely a bolt inside a plate. Sketches `JointDetailDocument`, whose honest property is that every collection can be EMPTY and empty is not zero — the viewer must distinguish "this joint has no stiffeners" from "we do not know", the same distinction `axesSymmetryOf` makes between `principal` and `unknown`. Without it an empty detail reads as a simple one. And the three views kept structurally distinct, with the confusion to avoid named: a pretty 3D viewer exported as a PNG and used as a shop drawing. The document carries revision, date and state; the viewer does not. ## M2-D and M2-E D: bases identified (`lib/cad/`'s 13 modules, `reinforcement-svg.ts` at 1504 lines as the closest precedent, `calc-report.ts`, `bar-marks.ts`, DXF R12), with the revision contract called out as the part that cannot be improvised. E: argued to be a consequence of B, C and D rather than a phase — attempting it first would produce a global view of markers that show nothing when clicked, which is worse than not having it because it promises. What CAN be done now and is useful alone: node markers with GEOMETRIC classification, filterable, which promise no detail because they insinuate none. ## Split Only phase A can be closed by M2 alone, and only as the gizmo variant. B, C, D and E need a PR M3 — new store entity, geometry, state vocabulary — documented here rather than opened, as instructed. And the joint typology, what gets verified, and the drawing symbology all need a human signature before any line of code.
…the giant spheres
Evidence only. **No change to `nodes-instanced.ts` or `Viewport3D.svelte`** — the
measurement is what decides whether that change is worth making, and under the
reordered M2 priorities it is not the next thing.
## Two hypotheses, both wrong, both refuted here
**«The spheres steal clicks from members.»** The mechanism is real: `Viewport3D`
raycasts `nodesParent` FIRST and only then `elementsParent` (lines 1145 and 1154),
so a node covering a member genuinely would make it unselectable. Measured across
five camera distances: **0 % at every one.** A 140 mm sphere does not reach the
quarter point of a 1.25 m truss panel. Kept as a permanent test rather than
deleted, because a larger marker or a generator with shorter members would make it
fire.
**«Node picking is failing.»** The first version of this bench aimed at each
node's mathematically exact centre and reported hit rates of 92 %, 67 % and 0 %,
which looked like a serious defect. It is an artefact of the probe:
`SphereGeometry` puts its poles on the Y axis, this app sets `cam.up = (0,0,1)` so
an elevation view looks exactly along ±Y, and a ray down a sphere's polar axis is
degenerate — characterised directly, it returns 16 triangles from one side and
**none** from the other, while ±X, ±Z and any off-axis direction return a clean
single hit. Real clicks land a pixel or two off centre, so the bench now samples
there and the hit rate is 100 % at every distance, before and after.
Worth recording because the wrong conclusion was one commit away: had I "fixed"
the picking, I would have been fixing a probe.
## What the measurement does support
The defect is visual and scale-dependent, and that is all. A world-fixed radius
makes apparent size inversely proportional to distance:
d = 18 m -> 8 px d = 4 m -> 36 px d = 1 m -> 144 px
An 18× growth over the range a user actually works in, and the zoomed-in end is
exactly where sections mode is used. A 7 px screen-space gizmo holds constant
across the same sweep with the hit rate unchanged and no clicks lost, so the fix
is measured to be free — the radius it resolves to at 1 m is 3.4 mm where the
fixed sphere is 70 mm.
Simulated by resolving the screen-space radius at each distance rather than by a
render loop, which isolates the property under test from the mechanism.
`raycastMs` is the query cost alone. No mouse, no frame budget, no person — it
must not be quoted as interaction time.
…oints M2's centre is the metallic design workflow, not catalogues or visualisation. This audits the nine points against what the code actually does and says what each one still needs. ## The finding that reframes point 9 **The CIRSOC 301 checker already exists, runs, and is deliberately gated off.** `engine/codes/argentina/cirsoc301.ts` is 769 lines of AISC 360 LRFD with six exported functions — tension, compression with Fe/Fcr, flexure with Lp/Lr and the lateral-torsional reduction, shear with Cv, the H1 interaction, and `verifySteelElement` — called from `verification-service.ts:336`. M1 had already audited it and declared every facet of every metallic capability false, with four reasons. Verified each: it has **zero tests** (no test file exists for that module), it cites no `ClauseRef` so `deriveMaturity` could not promote it past `UNSUPPORTED`, it is invoked with `Lb: L` at `verification-service.ts:332` — the whole member length as the unbraced length, which for a beam usually decides the answer — and it invents `tw = b/10`, `tf = b/15` and `fu = 1.25·fy` when the section or material lacks them. So point 9 is not "prepare a future verification". It is "decide what it takes to switch on the one that is already there", and three of the four blockers are bounded M2 work. Only the clause mapping needs a signature. Better news on the third: the checker takes `Lb` as its own honest parameter — the assumption is in the CALLER — and it connects directly to the bracing M1 already built into the shed generator, which is where a real `Lb` would come from. ## The audit Done: materials and grades, profiles and sections, parametric and built sections, generator integration. Partial: the regulation is selectable and visible but its declaration has no declared consequences; the four states are solid but `experimental.assumptions` is a field nobody fills because nothing produces an experimental result yet; limitations are covered by `capabilityGaps` and the five `conn.gap.*` while results are not, because there are none. **The hole is point 6.** `ProRcWorkflowTab` exists for concrete at 305 lines and there is no metallic equivalent. ## Point 6 can be built without touching a shared file `StageSection.svelte`'s entire API is `step`, `title`, `purpose`, `state`, `blockedBy`, `badge` — generic by construction, nothing concrete-specific. `ProRcWorkflowTab` consumes it rather than extending it, so a new `ProSteelWorkflowTab.svelte` can consume it the same way. Consuming is not editing. With one rule that has to be written before anything is wired: `state: 'done'` renders a ✓ in `--st-ok` (verified at `StageSection.svelte:74,173,187`), so a metallic stage may only reach `done` when it records a user's CHOICE, never when it could be read as "this was checked". Eight stages proposed, with the verification stage permanently `blocked` and its four blockers named — which is exactly why `steelCountsAsVerified()` returns the literal `false`. ## Joints, visualisation, and the node radius The joints research stays inside M2 where it is, not a branch. Recaps the three levels — topology solid, capacity present with five declared gaps and the bolt figure explicitly a ceiling, geometry absent entirely — and leaves the decision about whether visualisation belongs in M2 to an explicit call, not mine. The node bench is recorded as measured and held: the defect is visual only (8 px to 144 px across the working range), no clicks are stolen, picking is not broken, and a 7 px screen-space gizmo holds constant at no interactive cost. Not implemented, because under the new ordering a cosmetic fix comes after the workflow. ## What M2 cannot close The metallic verification itself. The workflow can be complete — regulation declared, material and section chosen, geometry and assumptions recorded, analysis run, results shown with their limits — and the last stage still say, with reasons, that nobody has signed the 769 lines that would produce the number.
…s blockers
Three things, all authorized: the workflow tab, the first tests the CIRSOC 301
checker has ever had, and the end of seven invented inputs. **Nothing is promoted
to verified**; `steelCountsAsVerified()` still returns the literal `false` and
every metallic capability stays gated.
## `ProSteelWorkflowTab.svelte` — eight stages, consuming `StageSection`
Concrete had `ProRcWorkflowTab`; steel had the pieces and nothing that put them in
an order or said which one blocked the next. A user could complete every step and
not know the last was never going to run.
`StageSection` is **consumed, not extended** — its whole API is `step`, `title`,
`purpose`, `state`, `blockedBy`, `badge`, `attention`, `testid`, `open`, none of it
concrete-specific, so a metallic workflow needs no edit to a shared file.
The governing rule, written into the component and asserted in the tests:
`state: 'done'` renders a ✓ in `--st-ok`, so **`done` may only mean the user
completed a CHOICE, never that a result was checked.** Regulation, grade, section
and analysis can complete. Geometry, assumptions, verification and limits cannot,
and the test proves it by checking that `'done'` does not appear in their
derivations at all.
Stage 7 is `const verificationState = $derived<State>('blocked')` — a constant, so
no input can move it — and lists five blockers with the two this branch addressed
marked as such. Addressed ones are dimmed, never green: colour there would read as
progress toward a pass, and the stage does not unblock on two of five.
No progress bar, no percentage, no cancel, no result. Each state is derived from a
fact a store already holds.
## Tests for the 769 lines
`cirsoc301-benchmarks.test.ts`, 18 cases, each naming the AISC 360 expression it
holds the checker to and evaluating it inline from the same inputs, so a wrong φ or
a lost factor of 1000 is a mismatch rather than a plausible number. Tension yield
and rupture with the governing switch, Euler's stress, both Fcr branches either
side of 4.71√(E/Fy), monotonic capacity with length, the Lp plateau and its
reduction, no LTB about the weak axis, Aw = d·tw, Cv compact and slender, both H1
branches, and determinism.
This removes exactly ONE of the four stated reasons the capability matrix gates
everything. The clause map, `Lb = L` and the human signature remain.
## Seven invented inputs, gone — and an axis swap nobody had named
`verification-service.ts` filled every missing property with a guess: `Fu = 1.25·Fy`,
`h = 0.3`, `b = 0.15`, `tw = b/10`, `tf = b/15`, `Iy = Iz`, `J = 0`. Now the element
is SKIPPED when an input is missing — which is what that loop already does for a
member with no section or a concrete strength — and `steelInputCompleteness()`
reports which elements and which inputs, so skipping is not silence.
The `Iy = Iz` fallback was the worst: it substituted one principal inertia for the
other, a factor of 13.7 on an IPE 200.
And a third defect, found while writing this and **not** in the four documented
blockers: `SteelDesignParams` documents `Iz` as the STRONG axis and `Iy` as the
weak, while this app's convention is the reverse — `section.iy` is the `b·h³/12`
term. The call site passed them straight through. Compression was unaffected
because it takes `max(KLrx, KLry)` and a maximum is symmetric, but
`checkSteelFlexure` takes `ry = √(Iy/A)` as the WEAK-axis radius and sets `Lp` from
it: fed the strong axis, `ry` came out ≈3.7× too large and a beam needing a
lateral-torsional reduction was judged inside the plateau — unconservative and
invisible. Fixed, with the swap spelled out so it does not read as a typo, and
pinned by a benchmark case that measures the ≥1.2× capacity inflation.
Safe to fix because every metallic capability is gated, so none of these numbers
reaches a user as verification.
Side effect: replacing the `as any` accesses cleared **6 pre-existing baseline type
errors**. Baseline lowered 479 → 473.
## `Lb` documented, not replaced
`m2-lb-assumption.md`: what it does today, that the checker takes `Lb` as its own
honest parameter so the assumption lives in the caller, and three ways a real value
could arrive — from the generator, which already places explicit bracing members
(`wallBracing`, `roofBracing`, `trussBracing` from M1's shed work) and throws the
relationship away; declared per member from stage 4 of the workflow; or derived from
topology, which is last or never because "what counts as effective bracing" is a
normative rule, not a geometric one.
Explicitly not replaced by `L/2`, `L/n`, or any fraction: that would trade a
declared conservative assumption for an undeclared and possibly unsafe one.
7266 unit tests pass (+45). Build pass clean. Typecheck: no new errors against the
lowered baseline. es/en/pt at parity, 233 keys each.
…carry content
The workflow was orphaned. This makes it the metallic tab, in the smallest change
that could do it.
## The mount is two lines, and replaces rather than adds
The `'steel'` tab already existed and rendered `<SteelPanel />`. The workflow is a
superset — it renders that same panel as its stage 8 — so mounting it is one import
and one element. No new tab, no `ProTab` union change, no panel reorganisation, no
concrete tab touched, no `StageSection` edit, and no workflow logic duplicated.
A ninth tab was the alternative and would have been worse: two metallic surfaces,
one a subset of the other, and a user having to know which. This is the same tab
with the stages around it.
## A regression I introduced, caught by the M1 suite
`SteelPanel` ended up inside a collapsed disclosure, so `pro-steel-panel` stopped
being visible when the tab opened — and `m1-states-and-languages.spec.ts` failed on
exactly that. It was right to: the inventory used to BE this tab, and putting it
behind a click takes away from every existing user the thing the tab was for.
Fixed as a product decision rather than by editing the spec: the limits stage opens
by default, and so does verification, because its blockers are the answer to "why is
there no result" and a blocked stage whose reasons are one click away is one nobody
reads. The six stages that record a choice stay collapsed — each is a one-line
confirmation, and six expanded would bury the two that matter.
## Tests
`m2-steel-workflow.spec.ts`, 14 E2E: the tab is reachable by the same ribbon route
as before; all eight stages render, in pipeline order read from the DOM rather than
assumed, each with real prose; verification is not `done` and names its five
blockers as readable text with no key leaking through; geometry is blocked on the
bracing datum and says `Lb`; no progress bar and no percentage; and the integration
property — `pro-steel-panel` appears exactly once, nested inside the workflow, with
the ribbon and the other PRO tabs still working after a visit.
**Two earlier versions of the claim-word test were wrong the same way**, and the
final one is better for it. Both banned words like "verified" outright, and both
tripped on sentences that DENY the claim: the note's "none is shown as verified"
and `SteelPanel`'s own "none of them is verified" from M1. Banning the word bans the
honesty. The property that actually matters is that **every line mentioning a claim
word carries a negation** — which permits the denials and still catches an
assertion. Plus a guard that the denials are present at all, so the check cannot
pass by having nothing to check.
## The four pre-flight verifications
* **The C/Z mirror is aligned**: `partsC`, `partsZ` and `section-shapes.ts`'s
`C-custom` all take `Math.max(0, c − t)`, and the geometry suite asserts the
module reproduces `computeSectionProperties` exactly.
* **`lipsCollide` uses `c > h/2`** — confirmed at `cold-formed.ts:136`. A stale doc
comment on `ColdFormedRejection` still documented the old `c + t > h/2` bound
and is corrected: a stale bound in a doc comment is read as the real one.
* **The five C/Z geometric verifications**: 74 tests across five files, all green.
* **Typecheck accounted for**: 479 → 473, and the six are enumerated below.
## The six type errors that disappeared
Five signatures, six occurrences, all `TS2339 Property does not exist` in
`verification-service.ts`, all from reading steel-only fields off
`AutoVerifyModelData`'s narrow `{ id, name, b?, h? }` section and `{ id, name, fy? }`
material types:
'a' ×1 'e' ×1 'iy' ×1 'iz' ×2 'j' ×1
`iz` twice because it was read once as `Iz:` and once in the `Iy: section.iy ?? section.iz`
fallback — the substitution that is now a declared gap instead. The
`SteelSectionData`/`SteelMaterialData` optional-extras types make the real objects
assignable, so the fields are declared rather than reached for with `as any`. Zero
new signatures added.
7266 unit tests pass. 107 E2E pass across every steel-touching spec. Build clean.
Typecheck: no new errors against baseline 473.
Stages 2 and 3 showed counts. A count collapses different problems into one number: three members "unresolved" might be one needing a grade, one carrying a withdrawn grade, and one made of aluminium that CIRSOC 301 will never cover. Three remedies, one number, no way to tell which is which. ## The rule that shapes every field **Nothing is fabricated, and a grade is never inferred from `fy`.** `material-family.ts` will guess a family from a yield strength and marks the guess. That is fine for deciding whether a member is metallic at all. It is not a grade: a grade is a designation in a product standard with a thickness table behind it, and no yield strength implies one. So an absent grade is reported absent — the designation cell prints an em dash, asserted in the E2E — not backfilled with a plausible name. ## Five states, because "not ready" has five meanings `chosen`, `incomplete`, `unavailable`, `outOfScope`, `authorityBlocked` — each with a different remedy, which is the reason they are separate. `outOfScope` reports a non-ferrous member rather than filtering it out, because a member that silently vanishes reads as one that passed. `authorityBlocked` is the only one no input will move. ## What each row adds over the count **Stage 2** carries the element id, the section name, the family AND its basis (with the inference caveat passed through, since a family shown without it is a guess made fact), the declared grade id, its designation, its product standard, whether the grade has thickness bands and **which standard tabulates them** — deliberately separate from the product standard, because the bands are a design code's table and showing them together implies the product standard published them — the governing thickness, and what is missing. Every missing datum carries **both** a name and a reason, and a severity that distinguishes them: a missing grade `blocks`, while a missing thickness on a banded grade `limits`. That second one is the distinction worth having — without a thickness only the first band is readable and the first band is the strongest, so the check still runs and returns the most favourable of several strengths. Not "unavailable", but "optimistic", and the row says which. A stored grade the catalogue no longer knows is its own case, distinct from no grade: the remedy is choose again rather than choose. **Stage 3** carries the origin — `tabulated`, `parametric`, `built`, `composed`, `unknown`, decided from what the section carries rather than a stored flag — the catalogue id, the properties present and the properties **absent by name**, and the distinction the brief asks for: `blockedBy: 'geometry' | 'authority'`. A geometric gap is the user's to close by picking a better section; an authority gap is not closeable by any input, so showing them the same way sends a user hunting for a datum that would change nothing. A cold-formed C is the authority case: complete geometry, and 301 excludes it by name. ## Tests `workflow-rows.test.ts`, 19 cases, mostly about DISCRIMINATION — that two members in different trouble produce different rows. Including: a declared grade and an absent one in the same model, with the absent one not borrowing the other's; every grade field null for a material with `fy: 300` and no grade; the withdrawn-grade case; the thickness gap as `limits` and not blocking; the flange-first thickness rule; the inference caveat passed through; an old bare section with every absent property named; geometry-vs-authority separated on two members at once; cold-formed recognised as parametric rather than tabulated; non-ferrous marked out of scope; and no state that reads as a pass. One harness finding worth keeping: `outsidePipeline` is only as good as the injected `lookupGrade`. Without it the inventory infers the family from `fy`, and an aluminium grade at 250 MPa infers as steel — so the test injects `catalogueGradeFamily` exactly as the store does, and says why. Five new E2E cases on a generated truss: a row per member each with its own state attribute, the em dash where a guess would go, the missing-datum reason as a sentence rather than a label, the origin named and not a leaked key, and no row state outside the five. Also corrected one of my own assertions: the contract test banned `data-state=` anywhere in the source as "that markup belongs to StageSection", which flagged the new tables' own row attribute. "Does not reimplement" is about the stage CARD, so it now checks for the absence of a rival `.stage` / `.marker[data-state]` in the styles. 7285 unit tests pass. 19 E2E on the workflow. Build clean. Typecheck: no new errors against baseline 473. es/en/pt at parity, 280 keys each.
The third of the verifier's four blockers, prepared as a contract. **It does not
remove the blocker**: every entry is `unvalidated`, the verification stage is
untouched, and no capability is promoted.
## Per expression, not per chapter
The existing `CIRSOC301_CLAUSES` map is `steelTension: '§D'` — a chapter, not a
rule, and `deriveMaturity` needs a clause. `cirsoc301-clause-map.ts` gives one entry
per expression the checker evaluates, each with the six fields asked for: the
expression as the code computes it, the clause, the inputs it consumes, its
assumptions, its limitations, and the human-validation state.
## The numbers were read out of the shipped text, and that changed them
`docs/codes/CIRSOC/markdown/cirsoc-301-2018/` carries chapters A–N, so every
citation was looked up rather than recalled from AISC. That mattered more than
expected: **CIRSOC 301-2018 numbers its expressions with dots** — `D.2.1`,
`E.3.2a`, `F.2.1`, `G.2.3` — where AISC 360 writes `D2-1`, `E3-2`, `F2-1`, `G2-3`.
Cited in the AISC style, every reference in this file would have been wrong for the
regulation the project declares. A test asserts the dotted form and rejects the
hyphen form.
## Three things the mapping exercise found
Mapping code to clauses is how you learn where the code is not the clause.
* **The flexural plateau has no cap.** F.2.1 reads `Mn = Mp = Fy·Zx ≤ 1,5·My`.
`checkSteelFlexure` computes `Mp = Fy·Zx` and stops — it never computes `My`,
confirmed by grep. For a rolled I-section `Zx/Sx` is about 1,1–1,2 and the cap
does not bind; for a high shape factor it can, and a missing upper bound is
unconservative.
* **The compression branches are the text's own alternative forms.** The code's
`0.658^(Fy/Fe)·Fy` is **E.3.2a**, not E.3.2, and its `0.877·Fe` is E.3.3
written through `λc² = Fy/Fe`. The text supplies both forms and states the
`kL/r ≤ 4,71·√(E/Fy)` equivalence the code switches on — so the code matches the
clause, but only reading the text shows which variant to cite.
* **`Ae = Ag`.** The rupture branch takes no hole deduction and no shear-lag
factor, which is right for a welded member and optimistic for a bolted one — and
the checker has no connection geometry to deduct from, which ties it to the
already-declared `boltGeometry` gap.
Also recorded: E.4 (torsional and flexural-torsional buckling) is not implemented,
and it governs for angles, tees and cruciforms — exactly the sections whose
non-principal axes M2 already warns about. And Cb is effectively 1,0, which is the
uniform-moment case and conservative.
## The validation state is the point
`ClauseValidation` is `'unvalidated' | 'reviewed' | 'signed'`, deliberately separate
from the capability matrix: a capability answers "can the app do this", this answers
"has anyone checked that this line implements that rule", and conflating them is how
an unreviewed mapping becomes a claim. The map is frozen so nothing can mark an
entry signed without a signature, and a test asserts `CIRSOC301_CLAUSE_MAP_VALIDATED`
is false and that the map gates nothing on its own — even fully signed it would
leave the unbraced length and the human signature standing.
14 tests. Typecheck: no new errors against baseline 473.
…makes
## Stage 5 — assumptions with provenance
An assumption a user chose and one the app made on their behalf are different
risks, and only the second is one they did not take knowingly. So every entry
carries a source: `user`, `generator`, `assumed`, `notInferable`.
Everything is `assumed`. `user` and `generator` are empty, and the second is worth
stating precisely: the shed generator DOES place explicit bracing members, and
`emit.ts` throws the relationship away — the role never reaches the stored
`Element`, and the section is named after its profile rather than its role. The
knowledge existed and was discarded. The empty slots are a more honest statement of
the gap than no slots at all.
`notInferable` is separate from `assumed` because the remedy differs in kind: a
better rule can improve an assumed value, nothing improves a field that does not
exist. Bracing points, the effective length factor and the hole pattern are there.
`Lb` is REPORTED, per member, as the member's own length — the real number the
checker receives, which varies (6 m and 4 m in the test, asserted separately). Not
`L/2`, not `L/n`, not a fraction of anything.
Every assumption and every not-inferable item carries a `routeOutKey`: what would
have to exist for it to stop being one. An absence with no route out reads as a
permanent property of the world, and most of these are not.
## Three assumptions retired, which is a correctness fix
`CIRSOC301_JS_ASSUMPTIONS` carried `webAndFlangeThicknessInferred`,
`ultimateStrengthInferred` and `noTests`. All three were true when written and none
is true now — this branch made the thicknesses and `Fu` required inputs reported
through `missingSteelInputs`, and wrote 18 benchmark tests.
Declaring an assumption the app no longer makes is not harmless caution. It is the
same defect as hiding one, pointing the other way: a reader who finds one warning
stale discounts the rest, and the unbraced length is the one that must be believed.
The test asserts it from both sides — the key is gone AND the engine really reports
the absence as a gap — so the list cannot drift back without one of them failing.
Four arrived in their place, each verified against the code during the clause
mapping: `Ae = Ag`, no `1,5·My` cap, no §E.4 torsional buckling, `Cb` fixed at 1,0,
plus the caller's `J = 0`.
One correction to my own evidence while writing that: I wrote that `grep -c Cb`
returns zero. It returns **six** — `Cb` is not absent, it is hardcoded
`const Cb = 1.0` under the checker's own comment «Simplificacion con Cb=1.0». The
conclusion held; the evidence cited for it did not, and the comment now says what
is actually there.
## Stage 7 — content without a result
Eight statements, each a fact about the code rather than a promise about it: that
there is no certifiable result, what exists in code (six functions, 769 lines), what
tests exist (18 benchmarks plus 14 on the clause map, and what they do NOT certify),
what data is missing, what needs a human, and the two departures the clause mapping
found — with their clause numbers, because without them the paragraph is an opinion:
* **`Ae = Ag` is not enough for a bolted member.** D.2.2 checks rupture on the
effective net area; the checker takes it equal to the gross. Correct for a
welded member, and on a bolted one the reported capacity sits above the true one.
* **The flexural cap is still missing.** F.2.1 reads `Mn = Mp = Fy·Zx ≤ 1,5·My`
and the code never computes `My`.
The clause map is shown as a count of UNVALIDATED entries rather than of mapped
ones: «14 mapped» reads as progress, «14 awaiting review» reads as what it is.
## The claim-word assertion, rewritten a third time
Same lesson, and it is worth recording because it kept recurring: banning words like
«verified» flags the sentences whose whole job is to DENY the claim. The contract
test now uses the property the E2E settled on — every line mentioning a claim word
must carry a negation — with two refinements the first run forced:
* **the claim is a participle, not the noun.** `verificad` also matches
«verificador», the name of a module, and naming it claims nothing. The patterns
end in `[oa]s?\b`, which accepts «verificado» and rejects «verificador».
* **whole strings, never split.** Splitting on `.` and `;` tore «El verificador
existe y produce números; ninguno se presenta como aprobado» in half and flagged
the first clause for lacking the negation that was in the second.
7316 unit tests pass. 26 E2E on the workflow. Typecheck: no new errors against
baseline 473. es/en/pt at parity, 316 keys each. `verificationState` is still the
constant `'blocked'`.
M2 is paused as instructed. No new joint or visualisation area was opened.
## What the handoff is for
QA needs three things this document gives: how to reach the new surface, what
should be seen there, and — the part that matters most for this branch — **what
must NOT appear**. Four prohibitions, each checkable: no green tick on stage 7, no
asserted approval word (every mention of one is a denial, and an assertion is a
first-priority bug), no grade inferred from `fy` where an em dash belongs, and no
progress bar, since there is no total to be a fraction of.
Plus the five regressions worth watching, including the two that legitimately
changed behaviour: a parametric C channel's properties are ~1.8 % smaller now that
the lip is measured from the outer face, and a zed no longer renders as a rectangle
in the 2D surfaces.
## What is honestly outstanding
Ten items, each with the reason rather than a date. Three of them cannot be closed
by any amount of code: the signature on the clause map, the decision on where `Lb`
comes from, and the ownership of the axes notice on surfaces that belong to concrete
or to Basic.
And the three consequential defects M2 found, with two still open — recorded so QA
knows they are known:
* the flexure axis swap, **fixed**;
* the missing `1,5·My` cap of F.2.1, **open**, because the code never computes
`My`;
* the 37 catalogued angles analysed about non-principal axes, **open and
pre-existing** — the stored weak-axis inertia is ~2.4× the true minimum, on the
unsafe side. The warning exists as a pure rule and is deliberately unmounted,
because every surface it belongs on is shared with concrete or lives in Basic.
Gates as measured: unit 7316, build 14, typecheck 473 with no new errors, 26 E2E on
the workflow, 107 across every steel-touching spec, es/en/pt at 316 keys in parity.
…d it
## The exact result of the full suite
**4 failed, 613 passed, 1.0 h** — 52 specs, `workers: 1`, 19:57→20:59. Recorded in
`m2-qa-handoff.md` §2 bis with all four, none classified yet.
A note on how that number was obtained, because it nearly went wrong: the
background command ended in `| grep | head`, so its exit code was **`head`'s, not
Playwright's**. Exit 0 there means nothing about the suite. The count came from the
captured output, not from the status.
## My own failure, and the assertion behind it
`m2-steel-workflow` → «no progress bar or percentage» failed on
`not.toMatch(/\d+\s?%/)`.
Measured with a throwaway probe rather than reasoned about: the workflow **does**
contain a percentage — «Corners modelled square: … It overestimates the area by
**0.77 %**, always in the same direction» — from `ColdFormedPanel`, which sits
inside `SteelPanel` at stage 8, open by default. `cold-formed-panel` and
`cf-corners` both present.
That is a **measured geometric deviation**, not progress. The prohibition is on
inventing a completion metric; banning the percent sign banned a number the panel
is right to show. Third time in this branch a blunt textual ban has flagged a
legitimate use — after «verified» twice — so the pattern is named in the test now.
The assertion checks what it should: no `progressbar` role, no percentage presented
as completion, and no state expressed as a fraction of a total. 27 pass isolated.
**And something I cannot explain, stated rather than smoothed over:** the old
assertion should have failed in isolation too, since the percentage is
unconditional. It passed in two earlier isolated runs of this spec (19 and 26).
I could not reconcile that, which means those two green results were weaker
evidence than I reported them as.
## The visual failure reproduces in isolation
`rc-design-visual` → overlay legend, re-run alone on a dedicated port (6211):
**fails again**, 23 s, load 12.96 → 11.60. Deterministic, not saturation.
Grounding gathered, no classification yet:
* the darwin baseline is **696 × 34**, which matches the failure's «expected»
exactly, so it is the right baseline that differs;
* it dates from **2026-07-25**, commit `15c74e18`, and has never been updated;
* **M2 touched no baseline** (`git diff` over `e2e/__screenshots__/` is empty);
* the legend is hardcoded RC verification markup in `Viewport3D.svelte` plus
three keys from the GENERAL dictionary, and M2 touched neither that component
nor those files.
So there is no path from an M2 change to that legend's width. Still not classified:
the remaining honest step is a run on the base branch, and the describe declares
itself `@slow visual baselines (non-blocking)`.
## Still to do
`project-restore` (900 s timeout) and `ded-roundtrip` (solve past 480 s, and the
test reports «fell back to sequential: no») are both timing failures and both
happened while the load average was 20.73 / 27.77 / 18.30 with 17 node processes
from other worktrees alive. Re-running them now would measure the same saturation,
so they wait for the machine to quieten. Nothing about them is classified.
No process was killed: the Playwright run finished on its own, and the M2 preview,
the QA server on 4004 and every other worktree's server are untouched.
…-run
All three open failures are now grounded. Every re-run used one spec, a dedicated
port (6211), no snapshot update, no timeout change and no force click.
## `project-restore` and `ded-roundtrip` — saturation, with the numbers
`project-restore` timed out at **900 s** in the full suite under load
20.73/27.77/18.30. Isolated, at load 2.8, it **passes in 104 s** — a factor of more
than eight against its own deadline.
`ded-roundtrip`'s 7-storey case failed on «the solve did not finish in 480 s».
Isolated, at load 3.1, the whole test **passes in 65 s**. And it drives the load to
**18.1 on its own** — 203 members, 1.46 M triangles, a 48 MB `.ded` — which makes
it the most saturation-sensitive test in the suite and explains why it was the one
to fall.
A nuance worth recording rather than overriding silently: that test's own failure
message says a solve that did NOT fall back to sequential «should be treated as a
regression». The rule does not account for external load, and the solve finishes
comfortably on a free machine — so the premise does not hold here. What deserves
review is the test's wording, not the solver.
Confirmed I re-ran the right case: the artifact is
`ded-roundtrip--slow-the-7--b3d39-page-that-has-never-seen-it`, which is the single
test inside `@slow the 7-storey project survives the file`.
## `rc-design-visual` — pre-existing, not an M2 regression
Four pieces, in the order they were gathered:
1. it reproduces isolated on M2 — deterministic, not saturation;
2. the `darwin` baseline is **696 × 34**, matching the failure's «expected»
exactly, and dates from **2026-07-25** (`15c74e18`), never updated;
3. M2 touched no baseline, not `Viewport3D.svelte` — where the legend is hardcoded
RC verification markup — and none of the three general-dictionary keys it uses;
4. **run on `feat/pro-steel-m1` it fails identically**: same 696→697 px, same 645
differing pixels.
So the classification is grounded on the base branch and not on absence of
evidence. Its own describe declares it `@slow visual baselines (non-blocking)`; a
one-pixel width consistent with font or Chromium drift, for whoever maintains the
baselines.
## Nothing killed, nothing left running
The Playwright run ended on its own. The M2 preview on 6103 was torn down by
Playwright; the QA server on 4004, the 4173 preview from `stabileo-landing` and
every other worktree's server are untouched.
The audit found definitions I had declared missing. Three are now implemented, one
is confirmed genuinely unavailable, and the human signature stops gating the stage.
## What the audit changed, item by item
**`Lb` — defined, and I had said it was not.** Apéndice 6 §6.1: «Las vigas con
puntos de arriostramiento intermedios que satisfagan las especificaciones de la
Sección 6.3 podrán proyectarse basadas en la longitud lateralmente no arriostrada
**Lb igual a la distancia entre puntos intermedios**», in cm. §6.3 also rules a
candidate out explicitly: «el punto de inflexión no será considerado un punto
arriostrado, a menos que se haya ubicado una riostra en esa posición». And §6.1
distinguishes **relativo / nodal** bracing for columns and lateral beam bracing
from **nodal / continuo** for torsional bracing, with §6.3.1 fixing where the brace
attaches — near the compression flange, except a cantilever's free end (top flange)
and double-curvature beams (both flanges).
What the model still lacks is the FIELD, not the definition. Recorded on the clause
map rather than as an unknown.
**`k = 1` is the code's provision, not my assumption.** Same §6.1: a column braced
at its ends and intermediate points per §6.2 «puede ser proyectada con una longitud
L entre puntos arriostrados y con un factor de longitud efectiva **k = 1**» —
conditional on the bracing complying, which the app cannot check.
**`Cb = 1` is permitted, not invented.** F.1.1 gives the formula and then says «Se
permite adoptar conservadoramente un valor **Cb = 1** para todos los casos de
diagramas de momento flector», and REQUIRES it for a cantilever with an unbraced
free end. So it was never an unsourced guess. Computing F.1.1 is now an
improvement on the map, not a defect: MA, MB and MC are the moments at the quarter
points of the unbraced segment, which the station demands can supply.
**`Ae` — the mechanism, exactly.** D.3(1): `Ae = An` when every element transmits
the force. D.3(2)(a): `Ae = An·U` with `U = 1 − x̄/L ≤ 0,9` (D.3.2). `An` per
B.4.2, deducting each hole at **nominal + 2 mm**. So `Ae = Ag` is exact for a
welded member and optimistic for a bolted one, and closing it needs a datum, not a
better rule.
## Two unconservatisms fixed, both needing no new input
**F.2.1**: `Mn = Mp = Fy·Zx ≤ 1,5·My`, with the clause giving `My = Fy·Sx` for a
homogeneous section. The cap was missing and `Sx` was already being computed two
lines above, used only for `Lr`. Now applied, and the steps say which branch
governed and print `Zx/Sx`.
**F.6.1**: the same 1,5 bound on the weak axis, written `Mn = Mp = Fy·Zy ≤ 1,5·Fy·Sy`.
It could not be applied because `Sy` did not exist; `computeSy` is `Iy/(b/2)`, the
mirror of `computeSx`. Also declared in the steps: **F.6.2** defines a flange local
buckling limit state for minor-axis bending and is not implemented.
## One gap confirmed as real, and it is data
**B.4.1** classifies sections against the λp/λr limits of Tables B.4.1a and B.4.1b.
**Those tables are images in the source PDF** — the shipped markdown carries their
captions, footnotes and symbol list, not the cells. So the classification cannot be
implemented from this repository. And it matters more than it looks: F.2 applies
only to doubly-symmetric sections and channels with **compact** flanges and web per
B.4.1, so the app may be applying F.2 outside its stated scope.
## The signature is metadata now, not a blocker
`verificationState` was the constant `'blocked'` because nobody had signed the
checker. That conflated whether the CALCULATION can run — a factual question — with
whether a human has reviewed it, which arrives later and cannot gate development.
It is now derived: `blocked` only when computation is impossible (no demands, or a
member with incomplete inputs), `current` otherwise, `optional` with no steel.
**Never `done`** — a green tick is the approval this must not invent, and
`steelCountsAsVerified()` still returns the literal `false`.
The stage now shows what it can honestly show: the calculation is available, the
clauses are traced (15, with the count of those awaiting human validation), the
limitations are listed, and professional review is pending — styled neutrally,
never `--st-ok`.
`BLOCKERS` became `LIMITATIONS`, with four now closed (tests, inferred inputs, the
clause map, the flexural cap) and three open for three different reasons (a model
field, table values that are images, connection geometry).
## Tests
Six new clause-branch cases: the cap not binding on a rolled I-section, binding
where `Zx/Sx > 1,5` — verified by computing both quantities from the same params
rather than hardcoding — the branch named in the steps, the weak-axis cap, and
F.6.2 declared as unimplemented.
Three of my own assertions had to be inverted or repaired, each because the code
moved under them:
* the clause-map test asserting the cap was MISSING now asserts it is applied;
* the «every entry has inputs» rule flagged `Cb = 1.0` and the unimplemented
B.4.1 — both legitimately consume nothing, so the rule now distinguishes a
formula from a constant;
* the axis-swap regression compared capacities, and the cap inverted that
comparison: swapping the inertias also shrinks `Sx`, so the cap clamps the
swapped case. It now measures `Lp` directly — `√(1943/142) ≈ 3,70` — which is
the mechanism, and was a confounded observable before.
7325 unit tests pass. 14 build. 59 metallic E2E. Typecheck: no new errors against
baseline 473. es/en/pt at parity, 322 keys each.
## `m2-cirsoc301-normative-audit.md`
The relevamiento the brief asked for, clause by clause against the checker,
`verification-service.ts`, the workflow inputs, the generators and the clause map.
Its own headline is the uncomfortable one: **I declared four gaps that were not
gaps.** `Lb`, `k = 1`, `Cb = 1` and the `1,5·My` cap were all defined in the text
this app ships, and I had not opened Apéndice 6 or `CLAUSE-INDEX.md` at all.
Per item, with what changed:
* **`Lb`** — defined in Ap. 6 §6.1 as the distance between intermediate braced
points, in cm; §6.3 excludes the inflection point unless a brace sits there;
§6.1 separates relativo/nodal from nodal/continuo; §6.3.1 fixes where the brace
attaches, with the cantilever and double-curvature exceptions. And it turns out
to need MORE than a field: the brace must meet stiffness and strength
«incluyendo los efectos de las uniones y detalles de anclaje», which the app
cannot evaluate. So the app can propose candidate points and cannot certify them.
* **`k = 1`** — the code's own provision under compliant bracing, not my
assumption.
* **`Cb = 1`** — «Se permite adoptar conservadoramente», and mandatory for a
cantilever with an unbraced free end. Computing F.1.1 is an improvement, and
feasible: the station demands can supply MA, MB, MC.
* **`Ae`** — the exact mechanism, D.3(1)/D.3(2)(a) with `U = 1 − x̄/L ≤ 0,9` and
B.4.2's nominal + 2 mm per hole. Confirms `Ae = Ag` is exact welded, optimistic
bolted, and needs a datum rather than a better rule.
* **B.4.1** — the one real data gap, and it is real: Tables B.4.1a/b are IMAGES in
the source PDF. Searched for the characteristic values (0,38 / 3,76 / 5,70 /
E/Fy) and found none. It drags the F.2 scope condition with it.
Also recorded: chapter F's own premise (§F.1(2), supports restrained against
rotation about the longitudinal axis), and the state of E.3/E.4/G.2/H.1/H.3.
## The handoff
Stage 7's description now matches the behaviour: `current` when the calculation can
run, `blocked` only when it cannot, never `done` — and the professional signature is
review metadata rather than a blocker. The `1,5·My` line moves from outstanding to
closed. Three items added that the audit surfaced: B.4.1 as data-blocked, §E.4 and
§H.3, and §F.6.2.
Block 1 of three. `Cb` was hardcoded `1.0` under the comment «Simplificacion». The
audit showed that value is not a defect — F.1.1 permits it explicitly — but
computing the clause raises the capacity for any non-uniform diagram, and the app
already has the diagram.
## The clause, and the floor that makes this safe
Cb = 12,5·Mmáx / (2,5·Mmáx + 3·MA + 4·MB + 3·MC) (F.1.1)
MA, MB, MC are the absolute moments at the quarter, mid and three-quarter points of
the **unbraced segment**. The expression's own minimum over any diagram is 1, so
computing it can only raise a capacity above the permitted conservative floor and
never lower it. That is why this can land without a signature.
## The scope it refuses to exceed
F.1.1 is stated for two cases: doubly-symmetric sections, and singly-symmetric ones
in SINGLE curvature. A singly-symmetric section in **double** curvature falls under
§F.1(4), which requires LTB checked «para ambas alas» with the strength compared
against the maximum moment compressing each — this app computes one `Mn`, so
applying F.1.1 there would be using a formula outside its case. It returns
`Cb = 1` with that reason instead.
A section with no axis of symmetry — a zed has point symmetry only, an angle none —
is in neither case. Nor is a shape the app cannot name: `unknown` is not a licence
to assume double symmetry.
`shapeSymmetry` is deliberately separate from `section/axes.ts`. Symmetry count and
principal-axis alignment are different questions with different consumers, and a
channel separates them: symmetric about one axis, and its geometric axes ARE
principal.
## The cantilever rule is mandatory and comes first
«Para miembros en voladizo, cuando el extremo libre no esté arriostrado, se deberá
tomar Cb = 1 para todos los casos, cualquiera sea el diagrama.» Applied before
anything is read, and the test uses a diagram that would otherwise compute 1,667 so
the ordering fails loudly if it changes.
## Plumbing
`runSteelVerification` gained an optional fourth argument for the full station
diagrams. It already received `stationDemands`, but those carry only the GOVERNING
station per category — interpolating quarter points from a handful of them would
produce a number that looks computed and rests on a coarse sample. So the real
diagram is asked for, and its absence means `Cb = 1` rather than a worse estimate.
One line at the shared call site in `ProVerificationTab.svelte` passes
`stationData?.stations`. Optional, read only by the steel path, and no concrete
behaviour depends on it.
## Tests, and one arithmetic lesson
21 cases. The four diagrams whose answer is known — uniform (1,000), simply
supported under uniform load (1,136), linear to zero (1,667), linear reversing
(2,273) — each with the expected value **evaluated in the test from the definition**
rather than quoted, so a reader sees why the number is that number.
The first run failed on the parabola: 1,1426 against an expected 1,1364. The module
was right. On an eleven-station grid the quarter points fall BETWEEN samples, so
linear interpolation cuts the corner off a parabola and MA reads 74 instead of 75.
The textbook cases now use a nine-station grid where the quarter points land on
stations, and the interpolation effect got its own test — including the direction,
since the coarser grid gives the slightly higher Cb. It is asserted so nobody later
"fixes" it into an assumption about the load shape.
Plus: the segment being a parameter (reading the second half of a diagram gives that
half's quarter points), every basis and reason resolving in es/en/pt, and the
limitation that must not get lost in the improvement — **computing Cb does not
certify the bracing**, because Apéndice 6 §6.1 requires a brace to meet strength and
stiffness «incluyendo los efectos de las uniones y detalles de anclaje».
No `Cb ≤ 3` cap: that limit does not appear in this clause, and adding it would be
adding a rule the shipped text does not state.
7346 unit tests pass. 14 build. 59 metallic E2E. Typecheck: no new errors against
baseline 473. es/en/pt at parity, 333 keys.
…lf-computable
Blocks 2 and 3. Neither yields a capacity, and the reasons are different enough
that reporting them as one «unavailable» would have thrown away real information.
## §E.4 — torsional buckling: the inputs are mostly there, and one is not
Audited before writing anything, and most of what the clause needs IS available:
`Ag`, `Ix`, `Iy`; `G = E/(2(1+ν))` from the material; and **`xo`, `yo` — the
shear-centre offsets `r̄o` and `H` are built from — which `section-teaching.ts`
already computes** via `shearCentreWorking`, for I/H/RHS/CHS/rect, T, U, C and the
angles. `J` is there on some sections.
What is missing, each independently fatal:
* **`Cw`, the warping constant.** E.4.9 is
`Fez = (π²·E·Cw/(kz·L)² + G·J)·1/(Ag·r̄o²)`. **No section in this app declares
one** — not the catalogue, not the templates — and the caller never passes it.
Verified by grep. This alone makes the general branch unreachable.
* **`kz`.** The clause fixes it: «kz = 1 cuando los extremos del miembro tienen la
torsión impedida y el alabeo libre». A boundary condition about the connections,
and the model records nothing about warping restraint. Assuming it would be
inventing one.
* **The classification condition of E.4.2(a)**, which covers Tees and double
angles «todas compactas o no compactas» — B.4.1 again.
* **The torsional unbraced length**, for the doubly-symmetric case: E.4 applies
when it exceeds the flexural one, and the model holds a single length.
So `e4Applicability` reports, per shape, whether E.4 could govern and exactly what
is missing. The distinction it keeps is the useful one: a tube is `outOfScope` —
nothing is missing, the clause does not reach it — while an angle is `inScope` with
named gaps. A blanket «unavailable» would pass a laxer test and say nothing.
`shearModulus` is exported because it is the one E.4 input this app can produce, and
it should be visible as such: a value with no formula to feed yet.
## §F.6.2 — three of four ingredients, and the fourth is an image
The clause is readable except for where it points at a table:
* **`λf = bf/tf`** — computable, and the convention is the trap: «bf para alas de
sección doble Te = **mitad** de la longitud del ala completa; para alas de
secciones canal = longitud **completa**». `λf` is squared in F.6.3, so getting it
wrong is a factor of FOUR. Its own test measures exactly that: the same flange
gives a channel four times the Fcr penalty of an I.
* **`Fcr = 138000/(bf/tf)²`** (F.6.3) — a closed expression with a literal
constant. No table.
* **`Sy`** — computable, «para secciones canal se tomará el módulo mínimo».
* **`λpf` and `λrf`** — «Tabla B.4.1b, **caso 14**», and that table's cells are
images.
So `f62Report` computes `λf`, `Sy` and `Fcr` and leaves the **branch undetermined**,
because a capacity that depends on which branch applies is not a capacity when the
branch is unknown. `Fcr` is labelled «if the flange turns out slender», not shown
bare — it is the number a reader holding the printed table can finish with.
Three states kept apart, which is what the brief asked for: `outOfScope`,
`geometryUnavailable` (a section datum the user can supply) and
`classificationUnavailable` (a table nobody here has). And a negative test that
matters: an IPE 200's flange at λf ≈ 5,9 is compact by any real table, so the
tempting shortcut is to return `notApplicableCompact` — which would be asserting a
limit value this repository does not have. The test forbids it.
## The workflow shows all three
Stage 7 now names Cb, E.4 and F.6.2, each with its state and — where there is one —
the specific reason and the computable half. Per shape rather than per member,
because these are properties of a shape and pretending otherwise would imply a
granularity the app does not have.
## One hand-arithmetic correction
I wrote `Fcr = 3990,5 MPa` for the IPE 200 flange. It is **3988,2**. The module was
right; the test now carries the corrected value with a note, since this is the third
time in this branch a hand-computed expectation lost to the code.
7378 unit tests pass (+32). 14 build. 59 metallic E2E. Typecheck: no new errors
against baseline 473. es/en/pt at parity, 359 keys each.
Stage 7's row gains the clause-availability line, and the outstanding list is
corrected in three places:
* **`Cb`** moves to implemented — F.1.1 from the moment diagram, within the scope
the clause declares — with the limitation that must travel with it: computing it
does not certify the bracing.
* **§E.4** is no longer «not implemented» but «audited and blocked on a datum»,
naming which: `Cw` is absent from every section in the app and E.4.9 requires it.
And what IS available is listed, because «unavailable» without that list
overstates the distance: `G`, the shear-centre offsets from
`shearCentreWorking`, `Ag`, `Ix`, `Iy`, and `J` on some sections.
* **§F.6.2** becomes «half computable»: `λf`, `Sy` and `Fcr` are computed and the
branch is undetermined, because λpf and λrf are Table B.4.1b case 14.
The distinction the handoff now carries, and QA should read it that way: a clause
blocked because a datum does not exist is not the same as a clause nobody wrote.
The brief asks for the map before the work, and the map changed the plan twice.
**Nothing has to be ported from main.** `a579d5b0` (SectionChanger + section-catalog)
and `2ab88694` (MaterialPresetSelector) are already ancestors of this branch, and
`git diff HEAD origin/main` on both selector files is empty. So this is not an
import job with provenance notes — it is re-pointing PRO at code already here.
**PRO's picker is a fork of the OLD Basic picker.** `ProSectionsTab` reads the flat
15-entry `FAMILY_LIST`, which is what Basic's `ProfileSelector` still does — but
Basic's real picker is now `SectionChanger` (938 LOC) over `section-catalog.ts`,
which already carries family, dimensional standard, standards body, country,
hot-rolled vs cold-formed, series and the four design codes. Every axis item 1 asks
to organise by already exists and is tested. That moves item 1 from "model the
catalogue" to "build the modal on it".
Two things PRO must not inherit: `SectionChanger`'s third **amorphous** tab (the
brief says exactly two divisions, structured), and Basic's implementation — its rows
are `<tr onclick>`, unreachable by keyboard, with seventeen hardcoded hex values and
no focus handling. Coherent with, not copied from.
**The contract is in the wrong place.** `ProfilePicker` already does arrangements,
gap and rotation; the type is `ProfileSpec` in `generators/emit.ts`, generator-private.
The brief needs composition usable outside generators and one source of truth for
rotation across selector, generator and 3D. Lifting it — and making the model able to
carry "two L 75x75x6 back to back at 10 mm, rotated 90°" — is phase 1, because items
1, 3 and 7 all read it.
**Two blockers traced to a line each.** The regulation gate: `roleUsable` returns
false whenever maturity is UNSUPPORTED, and CIRSOC 301 is declared UNSUPPORTED — so
`usable('steel')` cannot ever become true and choosing a code cannot unblock
anything. The maturity is accurate and stays; what changes is that progress gates on
`steelCodeDeclared` and only certification gates on `usable`.
The joints symptom is **not** where it looks. Reproduced the default shed twice, pure
and through the real stores: 300 joints, 625 metallic members, nothing filtered.
`detectJoints` and the `isMetallic` predicate are both correct. The cause is above
that layer, so phase 6 opens with a browser reproduction instead of a guess. Recorded
alongside it: my first probe compared `m.family === 'steel'` against a
`MaterialFamilyVerdict` **object** and reported 0 metallic elements — a bug in the
probe that would have read as a bug in the app.
**Pratt and Howe are swapped**, checked by statics rather than memory: cut a left-half
panel, the reaction is up, so the diagonal's vertical component on that free body must
be down — which is a diagonal *descending* toward the centre. The generator rises.
Warren does not exist at all; `WEB_PATTERNS` has two entries.
Also confirmed by inspection: the generator preview sits inline in a single scrolling
column with no sticky region, and 17 number inputs have no spin-button rule anywhere.
Eight phases, ordered by contract dependency, with phase 5 (truss geometry)
deliberately left unblocked — it is the one item decidable by statics alone. Three
things Phase 0 could not settle are named rather than defaulted, including battens,
for which no data exists anywhere in the codebase.
…erators
Phase 1 of the PRO selector scope. No behaviour changes; this is the contract
everything else in items 1, 3 and 7 reads.
## What was already true, and what the audit got wrong about it
I expected to have to teach the model about compound sections. It already knows:
`Section.composition` has stored `{profileName, arrangement, gapMm}` since the
generators started emitting it, `Section.rotation` is a separate degrees field, and
`three/section-profiles.ts:292` reads that field to extrude the REAL assembly outline
— without it a double-channel box chord rendered as a fabricated I-beam, which was
measured before the field existed.
So the gap is narrower and sharper than "the model cannot carry this". Grepping for
writers of `composition` returns exactly one file: `emit.ts`. **A back-to-back angle
could be obtained by generating a truss and by no other route**, because the type
describing one lived in `engine/generators/emit.ts` and was therefore generator-private.
A selector is not a generator. The vocabulary moved down a layer; the placement table
stayed in `built-up-section.ts`, where it belongs, and both files re-export the names
they used to own so every existing import resolves unchanged.
## The two representations differ in one field, and it is not an oversight
`ProfileSpec.rotationDeg` can be `'auto'` — defer to whatever roll the generator
computes per member, because a purlin on a pitched roof knows its own slope and a
picker does not. `Section.rotation` is a number, because a stored section has no
generator standing behind it to ask.
`specToComposition` therefore takes the resolved angle from the caller instead of
guessing one. Collapsing `'auto'` to `0` inside this module would silently flatten
every pitched purlin in a generated shed, so the round-trip test states the asymmetry
as a property rather than leaving it as a footnote.
Two more places where returning something plausible would have been worse than
returning nothing:
* a section with **no** composition gives `null`, not `defaultProfileSpec(name)` —
that would assert its name is a catalogue designation, which for a section built
from a template it is not;
* an **unknown** arrangement gives `null` too. Reading a later file format's
`quadHelix` as `single` would draw one profile where the file says four.
`specToComposition` also takes the resolved name separately, because `emit.ts` passes
`resolved.name` — what the user typed and what the catalogue matched are not always
the same string, and a stored section must hold the catalogue's.
## A test that keeps the two halves honest
The list moved and the placement table did not, so they can now drift. One test
compares `BUILT_UP_ARRANGEMENTS` against `Object.keys(ARRANGEMENTS)`: an arrangement
nothing can place, or a placement nothing can name, fails it. Another asserts
`emit`'s `defaultProfileSpec` is the *same function object*, not a copy that could
diverge.
20 new tests. Full suite 7398 passed (+20), typecheck clean against baseline 473.
Phase 5. Independent of the selector work, so it lands on its own — it is the one item in the scope decidable by statics alone. ## The swap ```ts const risesToCentre = (p.webPattern === 'pratt') === leftOfCentre; if (risesToCentre) web(bottomIdx[i], topIdx[i + 1], 'diagonal'); ``` On the left half, `pratt` ran bottom→top moving inward: the diagonals **rose** toward midspan. The module header said so too — "Pratt diagonals rise towards midspan and go into tension" — and the generator was built to match the header. Derived rather than recalled. Cut a panel in the left half and take the left free body. The reaction is upward, so the diagonal's vertical component on that body must be **downward**. A diagonal running from the top chord down to the bottom chord as it moves inward, in tension, pulls the body down-and-right — so a tension diagonal **descends** toward midspan. That is Pratt. Run it the other way and tension would pull the body up, adding to the reaction instead of balancing it; it can only be in compression, which is Howe. The Spanish, English and Portuguese labels carried the error too — «Pratt (suben al centro)» — so it was visible on screen, not just in a comment. **Why no test caught it.** The one test that touched `webPattern` compared the two patterns to each other and checked both were symmetric. Both are true of a swapped pair. The new tests pin the DIRECTION, which is the property a name refers to, and check it on a pitched truss as well as a level one. ## Warren New pattern, not a variant: alternating diagonals with **no interior posts**. The two end posts stay, and that is load path rather than pattern — without them the bearing has nothing carrying the reaction into the top chord, and the solver says so. Alternation is on the panel index, not mirrored about midspan the way Pratt and Howe are. Mirroring would put two same-leaning diagonals side by side at the centre and break the alternation the pattern is named for. One correction from writing the test: my first version measured lean relative to midspan, the frame Pratt and Howe are defined in. A Warren has no inward direction — it alternates straight across — and that frame flips sign at the centre, so it read as two neighbours leaning the same way when they did not. The test failed on a correctly built truss. It now measures in global x, and the comment says why the two patterns need different frames. ## Subdividing the diagonals Offered as "subdivide the diagonals", never as a surname, per the brief. Per panel it adds the post-and-diagonal set around two new panel points, and **splits** the main diagonal and the bottom chord at them rather than crossing them — a member laid across another with no shared node transfers nothing there, so the model would look subdivided and behave exactly as before. A test asserts every node has degree ≥ 2, which is what crossing would break. `subdivisionApplies` refuses a single panel per half, where the new point would land on the existing midspan one, and the panel hides the checkbox rather than offering an inert one. **The split segments are `frame`, not `truss`, and that is not a preference.** A sub-strut landing on a diagonal does not turn it into two members meeting at a gusset; it is one piece of steel with something attached partway along, exactly as `chordContinuity` already treats a chord through its panel points. Modelling it the other way does not merely overstate a joint — it fails: the new node's only members are two collinear halves and the post, nothing restrains it out of plane, and all three web patterns came back "Singular stiffness matrix — structure is a mechanism". Measured; the first version of the block did exactly that. Six new solver cases cover it: every pattern, plain and subdivided, on a 24 m truss. ## One existing diagnosis had to be re-derived `shed-default-solves` asserts that a roof without purlins is a mechanism and diagnoses what is missing by adding one restraint class at a time. Its negative half claimed no rotational restraint helps. Against the corrected web that is no longer true: clamping `ry` at the 33 roof nodes does remove the singularity, and properly — 3,29 mm peak against 3,96 mm for the `ty` case, so a real solution rather than a numerical accident. The diagnosis is unchanged, because `ry` is rotation in the frame's own plane and no component of a shed clamps that at every roof node — a purlin ties nodes sideways. So the test now names which restraint does what instead of over-claiming about all three, and records the `ry` result rather than dropping it, so the next reader does not repeat the assumption this replaced. 467 tests pass across the generator and i18n suites. Typecheck clean against baseline 473. es/en/pt at parity, 514 keys each.
Phase 5's E2E gate, kept apart from the geometry commit it completes.
The unit tests prove the shapes: Pratt descends toward midspan, Warren alternates
and has no interior posts, the subdivision splits rather than crosses, and no
pattern is a mechanism on the solver. None of that proves a user can SELECT any of
it, which is the whole point of the work.
So the spec asserts the three things a browser can settle:
* **Warren is in the list** — `WEB_PATTERNS` having three entries and the `<select>`
offering three are different facts.
* **The labels say the right direction.** «Pratt (suben al centro)» was wrong on
screen, in all three languages, not only in a comment — so the assertion is on the
rendered option text, not on the dictionary.
* **Ticking the subdivision adds members**, read off the count beside Generate. A
control that changes nothing visible is indistinguishable from one that is not
wired, and this is the cheapest way to tell them apart. It also checks the control
DISAPPEARS at one panel per half, where the new panel point would land on the
existing midspan one.
Plus one generation case per pattern, asserting the promised count is the count that
lands.
`gen-web-pattern` and `gen-panels` are new testids. The spec first selected these by
label, which does not survive the language sweep this scope requires at every phase —
the Spanish label for the web pattern is «Diagonales», not a translation of "web
pattern". A testid is the right handle for a control the suite drives in three
languages.
8 passed on E2E_PORT 6231.
…s about battens
Phase 2. The largest block in this scope, and smaller than expected for a reason
worth recording: most of what it needed already existed and was pointed at the wrong
place.
## Two divisions, and the third one is deliberately absent
Basic's picker has three: catalogue, template builder, and an **amorphous** section —
an area and an inertia with no geometry at all. PRO has two. A section with no
structure cannot be drawn, cannot be composed, cannot be classified and cannot be
checked against a clause, so offering it would be offering a section the rest of PRO
has nothing to say about. A test asserts the `Division` type has exactly two members
and that neither the markup nor the props ever name the third.
## It contains the existing picker rather than replacing it
`ProfileSelectorPanel` already does search, family/standards-body/design-code/depth
filters, grouping, pinned comparison and full keyboard navigation, and its own header
says the `ProfileSource` seam exists "so the general PRO section picker can hand it a
different catalogue". This is that picker. Rewriting the browsing would have produced
a second catalogue surface free to disagree with the generator's.
What the shell adds is what a row-anchored popover could not have: a centred dialog
with `aria-modal`, a Tab trap, focus restored to whatever opened it, a large preview
of the COMPOSED section, and the data sheet. Basic's pickers handle Escape and nothing
else — tabbing off their last control lands on the browser chrome with the modal still
covering the page — so this half of "coherent with Basic" had to be built, not
inherited.
## Battens: the code says more than I expected, and less than a drawing needs
Audited before writing anything, as instructed. §E.6.1 classifies built-up members into
five groups and **Grupo V is «cordones unidos por presillas a intervalos regulares»** —
so this is not an area the code leaves open. What it fixes, and what is now modelled
with the dotted clause on every row:
* **three segments minimum**, intermediate battens **equal and uniformly spaced** —
E.6.3.2(b)(2);
* battens at the ends, as close to them as possible — E.6.3.2(b)(1);
* with parallel planes, battens **faced** — E.6.3.2(b)(3);
* the chord checked with **unbraced length `a`** and k = 1 — E.6.3.1(b)(1);
* `a/ri ≤ 3/4` of the governing slenderness — E.6.2.2(a)(3), which is arithmetic and
is therefore computed rather than quoted.
And what it does **not** give: **no batten dimension appears anywhere in §E.6.** Not a
thickness, not a width, not a depth. The only property of a batten the clause names is
`Ip`, and only inside E.6.19's inequality `np·Ip/h ≥ 10·I1/a`; sizing is deferred to
Chapter F for the plate and Chapter J for its connections. So the geometry is
`GEOMETRY_UNAVAILABLE` with that condition quoted, and a test fails if a `thicknessMm`
ever appears in the module's output or the panel's source.
One limitation reads oddly on screen and is correct: **the spacing shows `—`**. `a` needs
the member length, and a section is a cross-section that sits on members of any length.
`L/3` against an assumed length would be exactly the fabricated geometry this scope
forbids, so the rule is named and the number withheld until the section is on a member.
The group is read off the arrangement rather than asked for, because that is the fact
it encodes: chords touching is Grupo I, whose rule is a maximum spacing; chords with a
gap need something spanning it. A crossed pair is neither of the five figures and comes
back out of scope rather than assigned a group it does not have.
## All fifteen families, through the resolver
`ProSectionsTab` mapped family to shape with a local function that knew six families and
returned `'CHS'` for the rest. Measured across all fifteen: **eight diverged from the
catalogue map, seven of them to CHS** — every American wide-flange, both American
channels, the tees and the square tubes. HEA came back `'H'` where the catalogue says
`'I'`.
Stiffness was never affected: `a`, `iy` and `iz` are written from the profile's own
numbers, and the canonical resolver returned identical properties either way — which I
checked before claiming otherwise. What a wrong shape breaks is everything dispatched on
it: the outline, the shear-flow path, the 3-D extrusion, and the clause helpers —
`flangeWidthForSlenderness` answers null for a CHS, so §F.6.2 would have called a
W-section beam out of scope. `familyToShape`, exhaustive by construction, was already
imported by that file and simply never called.
The new test does not compare two tables. It takes each family's real catalogue entry
through `toSectionFields` — the call the modal makes — and hands the result to the
canonical resolver: every family resolves geometry-backed except those the catalogue
declares `propertiesOnly`. And near misses resolve to nothing rather than to a
neighbour: `IPE 20`, `IPE200`, `IPE 201`, `HEA 200x` all return null.
## Three corrections to my own work
The contract test first banned the word "amorphous" outright and failed on the modal's
own header, which explains why PRO does not have that division — a comment DENYING the
thing read as offering it. It now checks the markup, plus the prop name anywhere.
`toSectionFields` first returned no `a` or `iz` for a catalogue pick, which would have
created a section the canonical resolver reports as having no known geometry — "amorphous
section" for something just picked out of a list. The properties now come from
`composeBuiltUp`, which knows which arrangements enclose a cell and therefore returns
`j: null` rather than a summed torsional constant that does not exist.
And I wrote a test asserting `'ipe 200 '` must NOT resolve. It failed, correctly:
`catalogueProfile` normalises case and surrounding whitespace on purpose. That is exact
matching after normalisation, and the test now says so instead of forbidding it.
7523 unit tests pass (+61). Typecheck clean against baseline 473. Build clean. 15/15 E2E
on E2E_PORT 6241. es/en/pt at parity, 592 keys each.
…ing away
The materials half of the selector scope, and it opens with a defect worth stating
plainly.
## An aluminium member was entering PRO as steel
`ProMaterialsTab.addPreset` wrote five fields — `name`, `e`, `nu`, `rho`, `fy` — and
dropped everything else the catalogue row carried: `gradeId`, `standard`, `region`
and `fu`. `materialFamilyOf` prefers a declared grade and otherwise falls back to
`fy > 80`, and that fallback cannot tell one metal from another. Measured before and
after:
* aluminium **5052-H32**, `fy = 195`, added through the PRO tab →
`{ family: 'steel', basis: 'inferredFromFy' }`
* the same preset with its `gradeId` →
`{ family: 'aluminium', basis: 'declaredGrade' }`
So an aluminium member joined the metallic inventory and was offered CIRSOC 301. 27
of the 28 steel presets carry an id, and every one of them was being discarded at the
moment of selection. `toMaterialFields` is now the single conversion the three
writing surfaces share, and all four families — steel, aluminium, concrete, timber —
are classified from a declaration rather than a magnitude.
## The panel stays; the modal contains it
I was going to replace `GradePickerPanel` with the new modal, and that would have
destroyed M1's §1 checklist — it pins `grade-list`, `grade-search`, `grade-family-*`,
`grade-region-*`, `grade-bands` and `grade-option-*` in detail, including §1.11,
which is the test that keeps thickness bands attributed to EN 1993-1-1 rather than to
EN 10025-2.
So the modal CONTAINS the panel. Metals get the deep panel; the non-metals get the
preset list. Two bodies because they are two data models — a `GradeEntry` has bands,
a family and a design-code filter, and concrete has none of that — not because they
are two layouts. One dialog, one focus trap, one Escape. M1's 31 tests pass unchanged.
Containing it immediately exposed a duplication: the modal's own search box and
region chips sat over the panel's better ones. Only the controls belonging to the
active body are rendered now.
## Two assumptions of mine that were wrong
I expected concrete and timber to carry no `gradeId`. **They do** — `H-20` is
`cirsoc-h20`, `C16` is `en338-c16`, and all six categories are fully identified. What
differs is where their properties live: `structuralGradeSource.byId` returns nothing
for them because they are described by `non-metal-grades.ts` and the catalogue row.
So the sheet distinguishes three states rather than two — identified, identified but
outside the metal database, and unidentified — and still shows the preset's numbers,
marked `typicalValue` rather than promoted to `productStandard`, which would be a
claim about a document this module never opened.
And I banned `structural-grades` from the modal outright, which flagged
`import type { GradeRegion }` — a type import that carries no data. The rule now
draws the same value-versus-type distinction the picker audit already draws.
## The seam that was missing
`steel-surface-audit` failed the modal for importing `data/material-presets`
directly, and it was right: profiles have `ProfileSource`, grades have `GradeSource`,
and the material catalogue had no seam at all. `MaterialPresetSource` is that third
seam, shaped like the other two, and the modal takes it as a prop.
The region filter moved inside it. It cannot live in `searchPresets`, whose `pro`
flag already means "do not gate by region" — giving one argument a second, opposite
meaning is how a filter starts contradicting itself.
## Focus took three attempts and a probe
Escape left focus on `<body>`. Deferring the restore did not fix it; neither did
latching the transition. An instrumented probe read `{captured: "profile-search",
afterRaf: "BODY"}` and gave the answer: **child effects run before parent effects**,
and the dialog's body focuses its own search box on mount — so a plain `$effect`
captured a control INSIDE the dialog as the thing to return focus to, and restoring
to a node being removed sends focus to `<body>`.
`$effect.pre` runs before the DOM updates, when the trigger still holds focus. All
three parts — pre-effect, latch, deferred restore — are pinned at the source level,
because none of them is visible in behaviour until all three are present.
51 unit tests. 12 E2E on E2E_PORT 6251/6281. Full suite 7578 passed, exit 0. Build
clean. Typecheck clean against baseline 473. es/en/pt at parity.
…ving two writers Phase 4. Four changes to the generator panel, three of them layout and one of them a duplication that was quietly wrong. ## The preview is docked, and unlocking returns it to the flow The panel was one scrolling column, so reaching the last parameter carried the drawing and the Generate button off the bottom — the feedback loop the preview exists for broke exactly when a user was using it. `position: sticky` does not fix this. Sticky keeps the element in flow, so on a short viewport the drawing still pushes Generate past the fold. What pins it is a flex column with one scroller and one footer, and the scroller needs `min-height: 0`: without it the default `min-height: auto` makes it as tall as its content and the dock is pushed off screen, which is the same failure again. Unlocking renders the SAME snippet inside the scroller instead of in the dock, so there is one preview and one Generate in the DOM either way rather than two copies that could drift. The dock is capped at 55 % so a tall drawing cannot take the panel. E2E checks Generate is fully on screen at 1280, 1024, 900 and 820 px wide. ## Number inputs 17 of them, 4–6 rem wide with right-aligned values, and WebKit draws the spin button INSIDE that box — so `24.50` rendered under a pair of arrows. `appearance: textfield` plus the `::-webkit-*-spin-button` rules remove the painted arrows and nothing else: `step`, `min`, `max`, arrow-key stepping and the `spinbutton` role are all still there, and the E2E asserts each of them rather than trusting the CSS. That test took a correction. I first read `getComputedStyle(el, '::-webkit-inner-spin-button').appearance` — measured, Chromium does not expose that pseudo-element at all and returns the INPUT's own style, `width: 96px` included. So the assertion was reading the element while claiming to read the arrows. It now asserts `appearance: textfield`, which is the mechanism. ## One writer for a section, not two The profile row carried arrangement, gap and rotation as its own controls, and the section modal carried them too. Two writers of one `ProfileSpec`, free to disagree the moment either was touched — the row could say `doubleBack` while the modal, opened a second later, showed its own draft. The row now shows the RESULT — the composed figure, the family, the area, how many parts — and the modal owns the spec. `ProfilePicker` hands it the whole object and takes back whatever comes out, so composing a back-to-back angle for a generator role and composing one in the sections tab are the same act on the same object. `generators-steel.spec.ts` G2 was updated to the new click path. Its assertions are untouched; only the route to the arrangement changed, and the test still proves the figure tracks it. ## The material trigger opens the PRO flow Narrowed to the metal categories, because a truss emitter cannot build anything out of C25/30 — narrowing the shared selector rather than shipping a second one, so the catalogue, the sheet, the keyboard and the conversion stay identical and only the tab strip is shorter. ## A36 Was a sentence explaining that the model comes out with a provisional material and declares it as an assumption. Accurate, and far too long for a line that is true until the user picks anything. Now «Grado provisorio: A36.» The line stating what a grade does and does not enable is unchanged — that is the part worth keeping. 18 E2E on E2E_PORT 6301/6311/6351, across four widths and three languages. Full suite 7578 passed, exit 0.
…assify
Phase 6. The symptom was "the app detects no joints" on the industrial shed. Two
earlier rounds of tests said the pipeline was fine, and they were right about the
model they used.
## Why the unit tests were not enough, stated precisely
Both rounds ran against a GENERATED shed: 300 joints, 625 metallic members, nothing
filtered. `detectJoints` is correct and so is the `isMetallic` predicate. The
SHIPPED example is a different model and was in a state no generated one can reach.
`3d-nave-industrial.json` carried **two materials both named "Acero A36"**: id 1 with
`fy: 250`, and id 2 with **no `fy` at all**. All 633 elements point at id 2; id 1 was
orphaned and used by nothing.
| Layer | Before | After |
|---|---|---|
| `detectJoints`, unfiltered | 226 | 226 |
| `materialFamilyOf` | `unknown` / `noData` | `steel` / `declaredGrade` |
| metallic inventory | **0 of 633** | 633 of 633 |
| `detectJoints`, filtered | **0** | 226 |
So the app found 226 joints and showed none, and the loss was entirely at the
`isMetallic` predicate — which was doing its job. `materialFamilyOf` returns `unknown`
for a material with no yield strength because it has nothing to classify with.
**The fix is on the data.** Relaxing the predicate to admit a material with no
strength would make every concrete model's joints metallic, which is the defect that
predicate was added to close. The material now declares `fy`, `fu` and
`gradeId: 'astm-a36'`, so the classification rests on a declaration rather than on
`fy > 80` — the same magnitude fallback that reads aluminium as steel.
## Three absences, not one
The panel rendered all of them as "no joints", which for this model was false. They
are now distinguished:
* **`noModel`** — nothing connects. Genuinely no joints.
* **`noneMetallic`** — the model has N joints and none of their members is
classifiable. It says N, says how many members are unclassified, and says what
usually causes it: a material with no strength and no declared grade. A concrete
model lands here, correctly.
* **`hasJoints`** — the ordinary case.
Only the second has an action attached, which is the whole reason to tell them apart.
## Tested where it broke
A fixture is a code path. `nave-industrial-joints.test.ts` walks the shipped file
through the real pipeline and asserts every material an element points at declares a
yield strength AND names its grade — so the next example that ships without one fails
here rather than in a user's panel.
The E2E loads the example a user actually opens: the count is real, rows name their
node and member count, selecting marks the row, the rows are keyboard-reachable, and
both empty states are asserted on models that actually produce them.
5 unit tests, 28 E2E on E2E_PORT 6301. Full suite 7578 passed, exit 0.
…tops Groundwork for the joints stage. Audited the shipped text before writing anything, as instructed, and chapter J turned out to be far more usable than B.4.1: **Tables J.3.3 and J.3.4 are transcribable rows, not scanned images.** So the geometric envelope a bolt layout must sit inside is computed, and every number points at the clause it came from: | Rule | Clause | |---|---| | `s ≥ 3·d` between hole centres | J.3.3 | | minimum edge distance, per diameter and per edge finish | Table J.3.4 | | `1,75·d` sheared / `1,25·d` rolled, above 30 mm | Table J.3.4, its own rule | | `12·t`, not exceeding 150 mm | J.3.5 | | `24·t ≤ 300 mm` painted · `14·t ≤ 180 mm` weathering | J.3.5 | | standard hole per diameter, `d+3` above 28 mm | Table J.3.3 | The edge-finish distinction is not decoration: a sheared, die-cut or punched edge takes 34 mm for a 20 mm bolt where a rolled or flame-cut one takes 26. ## The two things it refuses to do **It does not interpolate.** A diameter BETWEEN two table rows — 18 mm — returns null, not a value between 16 and 20. Interpolating a code table invents a limit the code does not state. Only ABOVE the table does a rule exist, and that rule is used. **It does not design a connection.** No demand, no bolt count, no plate outline, and it invents none of them. Given a diameter it reports the envelope; given nothing it reports which input is missing while still naming the clause, so a surface can show the RULE while saying it cannot evaluate it yet. A joint is not detailed because its bolt spacing has a lower bound. The three strength clauses — J.3.6, J.3.8, J.3.10 — are deliberately elsewhere. They need demands, and `connection-design.ts` already holds the bolt strength table. This is the geometry, which is the half answerable from a diameter alone. ## One distinction the test states rather than comments §B.4.2's «2 mm mayor que la dimensión nominal del agujero» is the NET AREA deduction; Table J.3.3 is the hole itself. A 20 mm bolt has a 22 mm hole and deducts 24 mm. Both numbers are in this app and conflating them would mis-deduct on every bolted tension member, so a test asserts they are different. 14 unit tests. Full suite 7578 passed, exit 0. es/en/pt at parity, 643 keys each.
Phase 7. Eight stages became five, and the eight were not wrong so much as they were
**the pieces in the order they were built**: regulation, grade, section, geometry,
assumptions, analysis, verification, limits.
## Five, and why these five
| # | Stage | What it holds |
|---|---|---|
| 1 | Modelling | what is modelled in steel, and whether it is solved |
| 2 | Regulation | which code is declared, and that declaring is not certifying |
| 3 | Sections, assumptions and verification | the five former stages |
| 4 | Joints | the real joints, and what can be defined about each |
| 5 | Documents | what a document will carry |
Grade, section, geometry, assumptions and verification were never five steps. They are
**one question asked about five inputs** — «are the sections I chose adequate» — so
they are `<section>`s inside stage 3.
**Each of them keeps its own state.** Merging five stages into one must not merge five
answers into one: a reader still has to see WHICH of the five is blocking, and both the
contract test and the E2E assert that per sub-section.
`limits` stopped being a stage and became a footer. It applies to every stage above it,
so numbering it after the last one implied it was something that arrives at the end.
C/Z get no stage. They are a family in the section selector, reachable from the sections
tab and from every generator row, which is where a shape belongs — a stage of its own
would make a shape look like a step a user completes. A test asserts no such stage and
no `ColdFormedPanel` import exists here.
## The gate that could never open
`roleUsable` returns false whenever a code's maturity is `UNSUPPORTED`, and CIRSOC 301
is declared `UNSUPPORTED` — accurately, no adapter implements it. So
`regulationsStore.usable('steel')` is **false by construction**, and gating progress on
it meant declaring a code could never unblock anything at all.
The maturity is not touched. What changed is the question: progress asks whether a code
is DECLARED; only a certified result asks whether it is usable. The stage says so on
screen rather than leaving it to be inferred from a stuck workflow.
## Two regressions my own restructure caused
Both were caught by tests, and both were content rather than plumbing.
**The Lb blocker vanished.** It lived on the geometry stage's `blockedBy`, and folding
that stage into a sub-section dropped it — the unbraced-length assumption being the one
thing that block exists to state. It is rendered inside the sub-section now, next to the
text it qualifies.
**The `done` rule iterated over states that no longer exist.** Rewritten over the new
set, and it now says which states MAY reach `done` and which never can: `regState` and
`modelState` may — a declared code and a solved model are facts, not verdicts about a
member — while `sectionsState`, `jointsState`, `documentsState` and `verificationState`
cannot. `steelCountsAsVerified()` still returns the literal `false`.
## One stale assertion, from an earlier decision
The E2E expected `signature` among the verification blockers. It stopped being one when
the signature became review metadata — a change that separated whether the CALCULATION
can run, which has a factual answer, from whether a person reviewed it, which arrives
later. The spec now asserts the seven real limitations and, separately, that the review
state is rendered as metadata and is NOT a blocker row.
29 contract tests, 41 E2E on E2E_PORT 6361/6371. Full suite 7597 passed, exit 0.
Typecheck clean against baseline 473. es/en/pt at parity, 671 keys each.
Prerequisite for making the modal the only way to create a section: measured first, because
removing the tab's inline form without this would have replaced a working path with a broken one.
`computeSectionProperties` returns `tw`, `tf`, `t` and `tl`. The built branch of
`toSectionFields` copied `a`, `iy`, `iz`, `j`, `b`, `h` and `shape` and stopped — `SectionFields`
had no thickness fields at all. For a catalogue pick that is correct: `resolveCanonicalSection`
finds the entry from the name and reads the published dimensions off the catalogue, which is the
only authority for a rolled profile's outline. A built section has no entry, so the resolver
switches on `shape` and calls `need('b','h','tw','tf')` against the section itself.
So every built shape with a wall came back `properties-only`. Measured on a lipped channel:
`missing: ['tw','tf']` from the modal, `geometry-backed` from the inline form, same parameters.
Undrawn, unextruded, and outside every clause helper that dispatches on shape. The first template
in the list, `hollow-rect`, already fails it — this was not an edge case.
Nothing caught it because the properties are identical either way: area, inertia, mass and every
solver result agreed, and only the geometry differed. The solver was never involved.
Three regressions, two of them on the effect rather than on the field list:
- the record carries the four thicknesses, and the lip specifically — `createSectionShape`'s
`case 'C'` substitutes the flange when `tl` is absent, so a dropped lip draws a different
section from the one whose properties were computed;
- a shape with no wall still writes no thickness key, so the record does not claim a question
was asked that does not apply;
- and, for every template, the modal path resolves to the SAME geometry state as the form it
is about to replace. Compared rather than asserted per template, so a template that is
legitimately properties-only cannot make it fail and neither path can become the poorer one.
The sections tab carried a whole second picker inline — the old Basic strip, fifteen family
buttons, a search box and a table whose rows added a section on click — beside the button that
opens `ProSectionModal`. Two surfaces onto one catalogue, and they did not agree.
The inline one was the poorer by four measures, each of which a user could hit:
- its rows called `modelStore.addSection` directly, so a section added there had no
arrangement, no gap and no rotation. It never built a `ProfileSpec`, which is the whole
reason a back-to-back angle chosen in a generator and one chosen here are the same object;
- the rows were `<tr onclick>`. No tab stop, no Enter, no role: unreachable by keyboard;
- it offered no standards body, no design code and no depth filter, so the catalogue looked
smaller here than it is;
- and the data sheet — the provenance of every number, and the fields the catalogue refuses
to derive — existed only on the modal.
The inline build form was the same story against the modal's `build` division: both read
`SECTION_SHAPES`, both wrote a `built` record, and only one of them was tested.
So this is a deletion, not a replacement — 724 lines to 205. What the modal was MISSING first was
fixed and pinned in the commit before this one: it dropped the wall thicknesses, so a section
built there had no geometry at all. Removing the working path before fixing the surviving one
would have been the trade this decision exists to avoid.
Verified before deleting, not after:
- every consumer of the inline selectors inventoried. `.profile-row`, `.profile-table`,
`.confirm-btn` and the rest are shared class names, and the E2E that clicks them —
`commercial-grade.spec.ts` — drives `/app/basic`, Basic's `SectionChanger`, not this tab;
- the generators reach the same modal through `ProfilePicker`, and the PRO tab through the
same trigger. One `ProSectionModal`, two entry points;
- all fifteen families still create a section with an area AND with `profileFamily` on it,
walked from `FAMILY_LIST` rather than from a number written down here;
- composition, gap, rotation, the `ProfileSpec` and the keyboard path all live on the
surviving surface and are what the deleted one never had;
- `ColdFormedPanel` is deliberately untouched. It creates sections too, but the cold-formed
C/Z catalogue is parametric and is not one of the fifteen — it has no modal equivalent, so
removing it would lose a capability rather than a duplicate. It is a third creation path
and it stays one;
- no solver, engine or calculation model is touched. `toSectionFields` and `composeBuiltUp`
already owned every number; nothing here recomputes any of them.
The disclosure goes with it: a `<details>` whose whole body is one button is a click that
reveals a click. The region keeps its test id and the E2E now asserts the trigger is reachable
without a gesture — the property that changed.
Three regressions on the tab, asserting absence of the machinery rather than absence of a
rendered table, because a table is easy to hide and easy to bring back: it imports no catalogue,
it writes to the model exactly once and through the choice type, and its way in is a button
rather than a row. They read the file with its comments stripped, so the component can still
explain why it is shaped this way without failing its own test.
One panel carried two verdict languages. The designed joint of §1 speaks `incomplete /
notVerifiable / designed / exceeded` — a vocabulary built so that none of its words can be read
as approval, with `verified` reserved for an authority that does not exist. §2 and §3 are the
older manual calculator: type a Vu and a Tu, press a button, read a utilisation. It has no mapped
clause, no test and no external benchmark. It said so at its own entry point, and then it ended
in a green ✓.
A tick beside a percentage is read as "the joint is fine". The disclaimer above it does not
survive the glyph, so the two languages disagreed inside one panel and the disagreement favoured
the half with no authority behind it.
Two places gave the tick, and only finding the second made the first worth fixing:
- **the result card.** `conn-status-icon` mapped `ok` to `✓` on `--st-ok`, and the weld card
added two more on its size range and its `L ≥ 4a` condition;
- **the section header.** `StageSection` paints `done` as a ✓ in `--st-ok`, and both sections
reached `done` the moment a result object existed. Not when the result was good: when it
EXISTED. A bolt group over its capacity turned its own header green — and so did pressing
Verify on the defaults, where Vu and Tu are 0 and the utilisation is 0% because nothing was
asked of the joint. A tick earned by supplying nothing.
Removing the glyph from the card and leaving it on the card's header would have moved the claim
rather than withdrawn it.
Converted rather than deleted, because the calculator is a tool a user may want and deleting it
answers a different question:
- the outcome is stated in words of its own — `within / near the limit / over the limit` —
and deliberately NOT in the canonical `adequate`, which would be the same claim in better
manners. In Spanish that collision is one word away: `conn.checkState.adequate` is «cumple»,
so a test asserts no auxiliary word is a canonical one;
- there is no success tone in the mapping to reach for. `within` is the panel's ordinary text
colour. Over and near keep their red and amber, because a warning is not an approval and
muting it trades one misreading for a worse one;
- each card says what it is before it shows a number, and says where the state that counts
comes from;
- the sections are `optional`, never `done`.
§1 — joint DETECTION — keeps its `done` deliberately. It means the detector ran and found
joints: a fact about a step, saying nothing about whether any joint is adequate.
The exemption `ProConnectionsTab` had in `steel-never-verified` is gone; it was a holding
position while §6.2 of the audit was open. `ProVerificationTab` keeps its own, and only it: that
tab genuinely shares a component with reinforced concrete, where a tick is a legitimate verdict,
and its steel half is guarded precisely by the green-tick-path assertion instead.
The sweep now strips comments before reading a component, so a file can record which glyph it
removed and why without failing the rule it satisfies.
Regressions: the source sweep, four assertions that the replacement is not a rename, a check
that only one `done` remains and on which section, and an E2E that presses Verify on the
defaults and reads the rendered card — glyph, wording, and the state's colour compared against
`--st-ok` resolved in the same document rather than against a hex written down in the test.
…eption to
`SectionFigure` was the one metallic component with hardcoded colour: `#071322` twice — the well
and the `isVoid` polygons inside it, which have to be the same value or a hole reads as a darker
solid — `#24486e` for the frame, and `#566` for the em dash shown where the catalogue has no
geometry. Every other metallic surface reaches for `--st-*` without exception.
No new token is introduced. `tokens.css` is H1's, and `m1-token-proposal-reconciliation.md` is
where that boundary is recorded, so the migration uses tokens the system already shares. Each
choice was measured rather than picked by eye, and one of the three came out against the obvious
answer:
- **well and void → `--st-bg`.** One token in two places, so "the void matches the container"
is structural rather than two literals kept equal by hand. The figure does not visibly change
colour: `#071322` against `--st-bg` is a ratio of 1.02, and against the row it sits on the
well goes 1.14 → 1.11. What it gains is agreement with the modal's own `.preview` well, which
was ALREADY `--st-bg` — a nested well that differed from its container for no reason anyone
had decided.
- **frame → `--st-hair-strong`, not `--st-hair`.** `--st-hair` is the obvious reading and it is
the wrong one. Composited on the row it gives 1.48, BELOW the 1.74 the literal had: the frame
would come out fainter than before, a regression measured in the units the decision asked
about. `--st-hair-strong` gives 2.03 on the row and 2.07 on the well, at or above the literal
on both grounds. There is a structural reason too — inside the modal this figure sits within
a `--st-hair` preview well, and a nested frame in its parent's own token is one nobody sees.
Neither value reaches the 3:1 WCAG 2.1 §1.4.11 asks of a boundary that identifies a COMPONENT;
this is a decorative frame around a picture, and the literal did not reach it either.
- **the em dash → `--st-text-2`.** `#566` on the old well was **3.02** and `--st-text-3` would
be 4.03: both under AA. This glyph is the whole visible content of the state where the
catalogue has no geometry and the component refuses to invent one — the last thing that
should be hard to read. `--st-text-2` on `--st-bg` is 7.00, closing a pre-existing failure
rather than carrying it across.
The void's fill moves from a presentation attribute to the stylesheet. Not because `fill="var(…)"`
would fail to render — the modal already hands this component a `var(--st-value)` stroke and it
draws — but because an attribute in the template is not something a test can relate to the rule
painting the background it has to match. The role colour becomes an inline `style`, which outranks
the class and carries both the fill and the opacity in one declaration.
No snapshot was updated, and none needed to be: the only two committed baselines are
`overlay-legend` and `batch-dialog`, both reinforced-concrete surfaces in
`rc-design-visual.spec.ts`. Neither renders this component. There is also only one theme —
`tokens.css` has no `prefers-color-scheme` and no `data-theme` — so "both themes" is a property
this change makes possible rather than one it can be tested against today: a literal cannot follow
a palette, and a token can.
Regressions: the literal rule is inverted and enumerated, so a metallic surface added later is
covered by default and the remaining debt has to be written down to pass — `TopologyPreview` is
what is left, nine literals including the same `#071322`, and moving one preview without the
other is a decision about how they relate rather than a cleanup. Plus an assertion that the two
declarations name the same value whatever it is called, and an E2E on a hollow SHS that reads the
void's computed fill and the container's computed background out of the browser and requires them
equal.
…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.
… proof
Every failure re-measured rather than inherited from the earlier audit. The control is a worktree
of `origin/main` with `node_modules` symlinked and the WASM artefact copied — `engine/` and
`package-lock.json` are identical between main and M2, so the only variable under test is the web
source. That mattered: M2 touched `nodes-instanced.ts` and `node-scale.ts` in `a47d6208`, which is
exactly what 3D selection and `viewport-perf` exercise, so "preexisting" could not be taken on
trust.
Four of the five have a twin on clean main whose failure text is identical character for
character. One correction to the earlier classification: `viewport-perf` is **not** a threshold
failure. It fails in `keyboard.up` at `orbitByKeyboard`, a held ArrowLeft that never releases
inside 60 s — same message on both trees, which is why a different model fails each run: whichever
one is running when it wedges.
`basic-demos:160` needed both conditions measured, and the first one was the misleading one:
- isolated, `--repeat-each=10`: 0/10 first-attempt failures on the branch, 1/10 on main;
- in context, the whole file `--repeat-each=3`: **3/3 first-attempt failures on both**, every one
recovered by a retry — and main needed `retry #2` once where the branch always recovered on
`#1`. The failing attempt burns ~25 s, the passing retry takes ~10 s.
So it is not a low-rate flake: it is deterministic and session-dependent. Alone it passes; run
after its siblings in the single browser session `workers: 1` imposes, it fails the first attempt
every time. `--retries=0` cannot suppress that — the spec declares
`test.describe.configure({ retries: 2 })` with its own note that landing a synthetic click on a
moving target is what is not deterministic. What the measurement adds is that the budget is spent
on every run rather than occasionally, and that main can need all of it. The `1 flaky` line in
every full-suite run is expected behaviour, not an incident. No timeout was relaxed and no
snapshot updated.
Part IV records the CI these four commits actually got, which supplies the same-sha proof Part I
had only borrowed from M1. `4b0afd2b` — all three decisions — is green. `8e6cc467` is that plus a
markdown file that enters no bundle, and its first attempt failed on `basic-demos:285`, the very
test that made M1 red. Rerun on the same sha: green. Three independent demonstrations that this
suite produces both colours on identical code, and `gh pr checks` shows only the last of them.
The audit's §6 is marked closed, §16 superseded, and the proposals document now opens with the
three divergences between what was proposed and what was built so that reverting any of them is a
decision rather than a discovery.
`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.
`.conn-ratio-badge` (§3.1) and the `svelte-check` silence (§3.2) were left open with evidence and no diagnosis. Part VI closes both, and each turned out to be something other than what the earlier reading said. §31 — the dead CSS was orphaned by M1, not by main. `git log -S` puts it on `b71432cd`, this branch's own sub-section split, two days BEFORE main's `2c79ed52` deleted the same block. So there is no product disagreement to adjudicate: M1 had already taken the badges off the screen. The contrast work of `851fd57b` was spent on rules that had no consumer when it ran. Removed in `6448e89d` on M1, merged down here, with three hand-written guards in place of the five assertions it took with it. §32 — `svelte-check` does read `ProConnectionsTab.svelte`. An injected type error is reported at 2:9 in the same run where an injected dead selector is not. The blind spot is upstream: `css-prune` stops pruning as soon as a component has a class attribute it cannot read statically, and this one has four. Measured over the whole tree: 30 of 169 styled components are blind the same way, 24 under `components/pro/`. Which is why no CI job is added. One would not have found §31 — a `svelte-check` job over `9883e2bd` would have printed exactly the 0 mentions the manual run did. The separate CI gap from Part I §2 is real and is now costed instead: 14.4 s for `check:gate`, 0 errors on every metallic path, so extending `GUARDED_PATHS` and adding one step to the existing `web` job is free — proposed with files, impact and tests, and left for whoever owns `ci.yml`, like the `concurrency` patch in §30. Parts III and §22 of the audit keep their original text, with a status line pointing forward, so what was known before the diagnosis stays legible.
…t as §32 §31 listed a guard asking that no class attribute on the panel carry `st-ok`, `st-warn` or `st-fail` written out. True — and true with the badges on screen too, since `statusClass` built the name by interpolation and the three strings were never in the file. Corrected in `66259cee` to assert the shape, and recorded here with the verification against `b71432cd^`. It is worth the paragraph because it closes the loop between the two findings: the interpolation that hid the class name from a grep is the interpolation that stopped Svelte pruning the rules. One defect, two symptoms.
One of the two assertions written to replace the removed ones did not survive. Recorded rather than quietly dropped, because the two failures are different and the second was caught by CI rather than by me: the first version asserted something that was equally true while the badges were on screen; the second banned a helper this branch's parent still applies to the pre-M2 auxiliary verdict, and broke M1's `web` job. Including the part that is mine: two assertions were written against M1 without running M1's suite afterwards. The second time it was run — 7400 green — before pushing.
The manual-QA walkthrough of everything a user can touch in M1 + M2 from 127.0.0.1:4004 — the
route to reach each item, the steps, the expected result, and what to look at even when the tests
are green. A green test is not visual QA: the suite asserts that an element exists, that a state
does not say "verified", and that a number changes when its input does. It does not assert that
something fits, that it reads, that it appears at the right moment, or that the drawing is right.
Two findings no earlier handoff has:
· B-01 — the inline material list is still a second source of creation. The same finding that
was closed for sections, left open for materials.
· I-06 / I-07 — joint designs are not persisted, and the store is not cleared when another
model is loaded.
Adds no code and opens no PR.
The materials tab carried THREE controls that added a material: an inline strip of preset buttons, the button that opens the dialog, and a `<details>` form for a hand-entered one. This is the finding that was closed for sections in 4a458b3 and left open for materials. ## What the defect was, and what it was not The two catalogue paths were not the old field-dropping bug. Both went through `toMaterialFields`, so `gradeId`, `standard`, `region` and `fu` travelled by either. What the inline strip did NOT have was the origin filter, the data sheet with the per-field authority, the thickness bands with the standard that publishes them, the deep grade panel, and the dialog's keyboard. A user who took the short route saw a poorer catalogue with no way to know a richer one existed — and the short route was the one nearer to hand. ## The order, which is the part that matters The custom form was the one capability the inline path had and the dialog did not, so it moved BEFORE anything was deleted. Removing the only surface that offers a capability is a different decision from relocating it, and only one of the two was asked for. 1. `MaterialChoice` gains `kind: 'custom'`. It emits no `gradeId`, no `standard` and no `region` — a hand-entered material HAS none of the three, and synthesising a field the source does not carry is the defect `material-choice.ts` exists to prevent, one direction over. 2. `CustomMaterialPanel.svelte`, new: the form, with bounds on the PHYSICS rather than on any catalogue. Poisson outside (-1, 0.5) means a negative bulk or shear modulus and the solver would take it; the inline form checked only `isNaN`, so `nu = 3` and `rho = -78.5` both reached the model. It also reads a decimal comma, which the inline form truncated silently: `parseFloat('0,3')` is `0`, a Poisson ratio it would have accepted. 3. `ProMaterialModal` gains two divisions, the same shape the section modal has. The second is offered only when the caller asks for it. 4. Only then is the inline path removed. `ProMaterialsTab` goes from 531 lines to 264: the trigger, and the materials table with its two per-material project settings, which edit a material rather than create one. ## `allowCustom` defaults to false, and that default is the interesting half The generators leave it off. Their `onApply` keeps `choiceGradeId(choice)` and nothing else, and a custom material answers that with `null` — so the division would put a control on screen that reads as "no grade chosen" after the user filled in five fields. The shared selector is narrowed again rather than a second one being shipped. ## Kept, and asserted Presets, the six categories, the deep grade panel with M1's pinned testids, `gradeId` / standard / region / fu / origin / thickness bands, access from the generators and the workflow, and the non-metallic materials — concrete and timber, which the inline strip defaulted to and the dialog does not, so an E2E adds one of each through the dialog. Solver and normative models untouched. ## The regressions Asserted as absence of the MACHINERY, not of a rendered strip: a list is easy to hide and easy to bring back. A tab that imports no `MATERIAL_CATEGORIES`, no `searchPresets` and no `MaterialPreset` cannot grow a second picker without this failing first. And `modelStore.addMaterial(` is COUNTED — exactly one, inside `applyChoice` — because counting is what catches a second writer that would be called something else. The `<details>` is gone with the picker it hid: a disclosure that reveals a button is a click for nothing. The E2E helper that had to open it first lost two lines, which is the measure of the step that was removed.
…th their model
## I-06 · the choices had nowhere to be stored
`jointDesignStore` held them in a `$state` Map and in nothing else: not in `snapshot()`, not in
`restore()`, not in the URL codec, not in any serialiser. Designing twenty joints of a shed and
closing the tab lost all twenty — the only capability in M2 whose result could not be kept.
They now live on `StructureModel.jointDesigns`, and the store is a VIEW over that field. The same
shape `regulations.svelte.ts` already has, for the same reason: what lives on the model travels
`.ded`, undo/redo, tab capture and autosave for free, and what lives in a side store travels none
of them. All four go through `snapshot()`/`restore()`, so testing that pair tests all four.
Still only the CHOICES — the store's existing rule, which now also protects a file. Bolts, plate,
weld and battens are stored; demands, capacities, plate outline and batten stations are recomputed
on read. A saved joint cannot report a check against a member that has since been deleted, and the
`.ded` is asserted to contain no `capacityKN`, `holesM`, `utilisation` or `checks`.
An absent field reads as "no joints were designed", which is the true answer for a project saved
before this existed. And absent STAYS absent in `snapshot()`: `restore(snapshot())` has to be a
no-op, because Cancel on a CAD draft is implemented as restoring the snapshot taken before Apply
and is asserted to undo exactly.
## I-07 · a node id is not an identity
`reset()` existed and nobody called it, so a second model loaded in the same session inherited the
first one's choices by id coincidence. What fails is not a capacity — `designFor` recomputes, so it
cannot report against a deleted member — but that the panel would present as CHOSEN something the
user never chose for this model. That is the defect shape this branch has chased three times: a
plausible value occupying the place of an absent one.
Two halves to the repair.
**One.** `restore()` and `clear()` REPLACE the field. A snapshot without `jointDesigns` lands as
`undefined`. That is why nothing has to remember to call `reset()`: replacing the project IS the
reset. A `reset()` nobody called was the defect; a `reset()` nobody NEEDS to call is the repair.
**Two.** Stored joints are reconciled against the open model on every read. Each one records what
it was designed against — `atMm`, the node position to the millimetre, and `memberCount`, how many
members touched it. A node somewhere else is a different node whatever its id says; the same place
with a different fan of members is a different joint. Three reasons, and the reason is what the
panel turns into a sentence:
· `nodeMissing` — no node carries that id: deleted, or this is another model
· `nodeMoved` — a node with that id exists, but somewhere else
· `topologyChanged` — same node, a different number of members meeting it
The order is asserted: a missing node also has no members, so checking the count first would
report every deleted node as a topology change and send the user looking for a member they never
removed.
Reconciled on every READ rather than once on load, which is what makes deleting a node mid-session
mark its joint obsolete immediately instead of leaving a design that matched at load time.
## Obsolete is a third answer
Not deleted — that would throw away work over a moved node. Not applied — that would be the silent
association. Kept, not applied (`choicesFor` answers empty), not iterated (`designedNodeIds`
excludes it, so a document cannot tabulate it), and SAID on screen with its reason and two
remedies: discard one, or discard all.
Saying it is the other half of the finding. An obsolete entry that is merely ignored is
indistinguishable from a joint nobody ever designed, and one of those two is work the user did.
## Two things measured wrong first
**A module-scope `$derived` returned stale values.** The store is created at module scope, so its
deriveds have no effect root: `set()` followed by `choicesFor()` returned the empty answer from
before the write. That is fine for a store only ever read from inside a component — which is why
`regulations.svelte.ts` gets away with it — but this one is read by the panel, the viewport and its
own tests. Replaced with a memo invalidated on REFERENCE identity of the three inputs, which is
sound here rather than a shortcut: the model reassigns each Map wholesale on every mutation, the
convention `CLAUDE.md` states because `SvelteMap.set` does not reliably re-render. Three `!==` per
read, and two tests pin the dependency through its observable consequence.
**`NaN` across the file boundary.** The sentinel for "node absent at write time" was
`atMm: { x: NaN, ... }`. `JSON` turns `NaN` into `null`, and `null` coerces to `0` — so the entry
came back matching any node at the origin. The fingerprint is now OMITTED, and the absence is the
claim that the joint cannot be vouched for. Plus three `Number.isFinite` checks before comparing,
for a hand-edited project that carries `null` coordinates.
## Coverage
Unit: the pure reconciliation (17), the store through the model (26), and the surface contract
(12). E2E: the seven steps of the cycle — design, save, reload, choices survive, change model,
cleared or obsolete, no joint dragged in from the other model — driven through the real Save button
and `pp-open-file`, with the assertions read off `model.jointDesigns` rather than off the panel. A
spec that asserted the panel still showed the bolts would pass just as happily on a panel that had
quietly kept a local copy, which is the shape of the defect rather than its absence.
…with their model
I-06 put the joint choices on `StructureModel.jointDesigns`, so they travel `.ded`, undo/redo, tab
capture and autosave. The URL codec was not one of those paths: it is a separate versioned wire
format that enumerates what it carries, so a shed with twenty designed joints shared by link
arrived with twenty undesigned ones. Three surfaces build that link, and the worst of them is the
feedback widget — a user reporting a problem with a joint attached a link that rebuilt the model
without the joint the report was about.
## The format
One new TOP-LEVEL key of the compact object, and no position added to any existing tuple:
jd: { v: 1, j: [ [ nodeId, memberCount, [x,y,z] | 0, choices ], … ] }
Four fixed slots per joint, with all growth room inside `choices` — four groups (`b`, `p`, `w`,
`ba`) of one- and two-letter keys, the same length discipline the section tuple already follows,
because `MAX_URL_SAFE` is a limit `ToolbarProject` actually checks.
`SHARE_VERSION` 4 → 5, compatible both ways. An old link carries no `jd` and reads as "no joint
decisions" — not as an empty joint designed at every node. A new link read by an older build takes
both existing migration branches, since they key off `sv >= 3` and `sv >= 4`, and ignores the key
it does not know. Unknown keys inside `choices` are ignored here too, so a field added later does
not make this build reject a later link.
## Only the choices, and only what can be vouched for
`packJointDesigns` walks a field table and emits nothing else, so a `capacityKN`, a `utilisation`
or a `checks` array that reached the model through a hand-edited file cannot reach a URL. Asserted
by tampering with a model on purpose and grepping the wire.
Obsolescence is NOT carried: `nodeMissing`/`nodeMoved`/`topologyChanged` are decided against the
model that is open, on every read. What is carried is the fingerprint they are derived from —
`atMm` and `memberCount` — because that IS part of the persisted decision: it records the joint
the user was looking at. Carrying it is what extends I-07 to the link. A link opened over another
model reports its joints obsolete, with a reason and a remedy, instead of matching them by node id
and presenting as chosen something nobody chose there. A payload with no fingerprint reconciles as
`nodeMoved` — never as a match.
## Refused, not half believed
A known key with the wrong type, an entry that is not a well-formed tuple, or a container version
this build cannot read all throw, and `decompressV2` turns that into the null its callers already
handle. Rejecting the whole link rather than dropping the joints is the deliberate half: the model
bytes and the joint bytes arrive in one deflate stream, and treating half of a stream that is not
what it claims to be as trustworthy is the weaker position. The cost is written down rather than
hidden — see §7.3 of the handoff.
## The drift guard, which is the piece §6 of the handoff asked for and never had
Two halves, and both were verified by breaking them on purpose:
- drop a field from the codec's table → the completeness test fails, by name;
- add a field to `JointChoices` → `npm run typecheck` reports ONE NEW error, pointing at the
`Required<…>` fixture that no longer lists every key.
That is how four section fields came to be missing without anyone noticing, and it is now the
thing that cannot happen again to a joint field. The same pattern is written up for
`interface Section` to copy.
Measured, not assumed: twenty fully designed joints cost 391 characters — 19,6 each, the first 203
and each one after it about 10, because the key names repeat and that is what deflate collapses.
The budget in the test is 30, half again over the measurement.
The codec is a module of its own because `url-sharing.ts` imports four stores, which is why
`url-sharing.test.ts` tests an inlined COPY of the encoder. A wire format tested through a
duplicate is a wire format with no test; these tests import the code that runs.
The four section fields of I-08 are untouched and still open. They are preexisting, they belong to
concrete and steel alike, and `built-section-contract.test.ts` still asserts their loss.
A-01 is the one item of the QA inventory that is a product decision rather than a defect, and the
inventory ranks it third by priority — not because it is broken, but because reverting it after a
merge costs far more than reverting it now.
So it is written up as a decision, not as a task: what the user sees today against what they saw
on `main`, which of the three withdrawn ways in was equivalent and which was not, whether any data
is lost, whether QA sees it, whether it blocks the merge, the minimum correction, and four
alternatives with what each costs.
The findings, stated rather than implied:
- **No data loss.** No format changed and no saved project reads differently. The prerequisite
the decision exposed — a section built THROUGH THE DIALOG dropped `tw`, `tf`, `t` and `tl` —
was fixed before anything was deleted.
- **The inline profile table was not equivalent** to what survived: it called `addSection`
directly, so a section entered with no arrangement, no gap, no rotation and no `ProfileSpec`;
its rows were `<tr onclick>`, unreachable by keyboard; and it offered no standards body, no
design code and no depth filter, so the catalogue looked smaller than it is.
- **The inline build form WAS equivalent**, and the survivor is the better of the two: same
lists, same computed properties, same `built` record, plus an `initial` that lets a built
section be reopened and edited, plus the only tests of the pair.
- So what actually changed for a user is **one extra click** to reach the builder, and a habit
break with no signpost. That is the whole of the divergence from the written proposal, which
said the builder was not to be touched.
Nothing was implemented. It changes visible behaviour, so it stays a pending decision, and the
recommendation is stated rather than acted on.
Also updated: the open list of `m1-m2-b01-i06-i07.md`. Two of its four items are closed — `main`
is in M1 and the joint half of I-08 is done — and they are struck through rather than deleted so
the order things happened in stays readable. The CI-gap item gains the consequence it was missing:
the `e2e` job runs `--grep @smoke`, so no unlabelled spec has ever run in a PR, and one of them
was broken from the start.
Batuis
added a commit
that referenced
this pull request
Aug 27, 2026
…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.
`basic-demos.spec.ts:285` fails on CI, and the artefact pins the cause: at the moment of failure the Advanced menu still holds `button "Section Analysis"`, so the section-analysis panel was never opened, and the `pick` step's only advance condition — `resultsStore.stressQuery !== null` — could not become true. The click did land; a station probe reads `x/L = 50.0%`. Reading the walkthrough against the test explains it. `arm` sets the select mode but leaves opening Section Analysis to the reader, which is the point of the step, and declares no `waitFor`. So `advance(page, 'pick')`, which only presses Next, walks past it, and the walkthrough then asks for a station on a panel that is not open. The test skips the one action the step exists to demand, and when it passes it passes for a reason it does not control — which is why it reads as a flake. Not patched, and the reason is stated: the repair is a choice between having the test perform the reader's action and giving `arm` the `waitFor` that would make the skip impossible. That is a decision about what the walkthrough demands of a reader, and it belongs to whoever owns the tour, not to a steel branch. Also recorded: the 5 s default vitest timeout against shed solves. The pair that did two solves in one test is split (761 ms and 765 ms); the 2,89 s single solve that remains has nothing to split and is slow because the unbraced shed is near-singular by design, which is the assertion itself.
…is not evidence The addendum said the `e2e` job of #164 was red. It then passed, on a commit that changed documentation and not one line of code, immediately after failing three times running. Corrected to what is actually true, which is a stronger statement than the one it replaces: the outcome does not depend on the code under test, so the green tick this branch now carries is not evidence that the section walkthrough works. The located cause stands unchanged — the step waits on `resultsStore.stressQuery` and the test never opens the panel that would set it — and so does the reason it was not patched here.
… is what is actually known The addendum claimed the section-walkthrough flake is the test skipping the reader's action of opening the Section Analysis panel, which the `arm` step leaves to them. That is false. `resultsStore.stressQuery` — the `pick` step's only advance condition — is written in exactly one place in 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 and not the cause. Publishing it as the cause would have sent the next reader to the wrong file. What the artefact establishes is kept; what it cannot separate is now named as two candidate causes — the clicks never landing inside the 0.3 m capture radius, or the select mode not being 'stress' when they did. Which of the two it is, is unknown, and the finding is labelled "narrowed, not diagnosed" rather than dressed up as an answer. Also recorded: `main` reached the same flake independently in `0844f01d`, declared retries, and labels itself "containment, not a diagnosis". That containment is not in these branches yet — it arrives with the next `main` merge.
…e model's §7.1 presented `jd.v` being the same constant the `.ded` writes as a virtue — one version for the field across both formats — and said nothing about what that costs on the next change. It costs this: `unpackJointDesigns` rejects the WHOLE LINK on a container version it cannot read, by the deliberate decision in §7.3. So whoever next increments `JOINT_DESIGNS_SCHEMA_VERSION` for a `.ded` reason makes every already-shared link stop opening entirely — not "without its joints". Unreachable today, with one version in circulation, and a trap for the next person rather than a defect now. Written down with both ways out and with the reason neither is mechanical: separating the URL container version from the model's, or reading an unknown `jd` version as "I cannot read these joints" instead of "I cannot read this link". The second contradicts §7.3, so it is the same trade-off that section already made and it has to be chosen on purpose. Left for M3, not patched here — the branches are frozen for review.
Batuis
marked this pull request as ready for review
August 27, 2026 19:52
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on M1 — do not merge before M1
This branch starts from
feat/pro-steel-m1@f936f29cand its base isfeat/pro-steel-m1, notmain. Intended order: M1 (#156) → M2 (this) → M3. Both PRs stay in draft and stay separate.Neither is closed, neither is rebased, neither has its base changed.
What M2 delivers
Metallic (steel) work in PRO, from the section a user picks to the joint they design.
1 · The PRO section selector
Two divisions and no third one. A section is either chosen from a catalogue or built from a
named template — there is no amorphous option where a user types an area and an inertia with no
geometry, because such a section cannot be drawn, composed, classified or checked against a
clause. Composition, gap and rotation live in one modal, so composing a back-to-back angle for a
generator role and composing one in the sections tab are the same act on the same object. The
cold-formed C/Z work sits inside that selector rather than as a workflow stage.
2 · The PRO material selector
gradeId,standard,regionandfupersist. Aluminium, timber, concrete and steel classifycorrectly, and the panel distinguishes an identified grade from a typical value outside the
metallic catalogue and from an absent grade. Each field on the data sheet carries its provenance.
3 · Generators
Docked preview, profile and material rows that reuse the selectors above, and truss geometry that
matches its name: Pratt diagonals descend toward midspan, Howe is the inverse, Warren
has no posts. The subdivision option is a checkbox and is not called "Baltimore" in the interface.
4 · The metallic workflow, in five stages
The regulation gate depends on
steelCodeDeclared, not on whether CIRSOC 301 is mature enough tocertify. A stage that cannot be opened says which of its prerequisites is missing.
5 · Joint design
Not a minimum envelope dressed as a designed joint. Per-joint demands are walked across every
combination and never mixed between them, with the governing demand reported per component
together with the combination, element and end it came from.
Implemented with the clause attached to every number: §J.3.2/Table J.3.3 (hole sizes), §J.3.3
(
s ≥ 3d), §J.3.4/Table J.3.4 (minimum edge distance), §J.3.5 (maximum spacing), §J.3.6/TableJ.3.2 (bolt strength), §J.3.7 (combined tension and shear), §J.3.10 (bearing), §J.2.1 (end-loaded
β), §J.2.2 (throat and leg limits), §J.2.4/Table J.2.5 (weld metal), §E.6.1 (the five groups),
§E.6.3.1 and §E.6.3.2 (batten stations and chord unbraced length), §E.6.19 (the stiffness
condition).
What is deliberately not produced: batten plate thickness, width and depth. §E.6 names no
batten dimension anywhere — the only property it gives is
Ip, and only insidenp·Ip/h ≥ 10·I1/a.Those come back as
GEOMETRY_UNAVAILABLEwith the condition they would have to satisfy, never asan invented dimension. Fillet welds top out at
notVerifiablebecause Table J.2.5 refers the basemetal to Chapter J.4, which is documented as a bounded future extension (tension, shear and block
shear of the connected elements) rather than implemented here.
designedis never presented as approval.6 · 3-D visualisation
Selection is one channel in both directions: selecting a node opens its joint in the panel, and
selecting a joint in the panel selects the node. A joint with no producible geometry draws
nothing rather than drawing a placeholder.
Two classes of defect this branch found
A plausible value standing in for absent data. Three times, under three different names:
fy, so all 633 elements classified asunknownandthe joint inventory reported 0 of 633. Fixed in the data, not in the predicate.
getJointForcesreadNI/NJ; the real fields arenStart/nEnd. Every lookup returnedundefinedand fell through to?? 0, so the connections panel showed a table of zeros forevery joint in every model — and a zero force reads as an unloaded member, not a missing one.
Number(value) || 10. Zero is falsy, so a deliberate 0 became 10 and§E.6.1's Group I — chords in continuous contact, carrying no battens — became unreachable while
the section went on claiming battens for it.
The third is now generalised: one parser keeps a real zero, an empty field and an invalid entry
apart, every field declares what zero means for it, and no
Number(...) || defaultremains insrc/components/pro/.A correct state a user could not leave. The batten form lived inside the "layout available"
branch, so typing a gap of 0 unmounted the very control that had just been used.
Gates
npm run test:unitnpm run test:buildnpm run typechecknpm run buildThe renderer crash, found and mitigated. About one test in ten that loaded the shed and
solved it failed with
page.evaluate: Test timeout of 60000ms exceeded. It was neither a hang northe solver: the page crashes, Chromium aborts with
signal 4, ILL_ILLTRP, andpage.evaluateon a crashed page does not reject — it waits. That one fact made three readings of the symptom
wrong at once: the page's own
setTimeoutnot firing, a 60 ms solve never returning, and thesequential fallback never running.
Localised by measurement, one variable at a time, 40 sessions per arm — pool disabled 0/40, one
worker 0/40, small model 0/40, loaded but never solved 0/40, workers created serially 7.5 %, one
compiled module per worker 12.5 %,
--no-wasm-tier-up15 %, full Chromium channel 7.5 %, Chromium151 22.5 %, trace and video off unchanged, peak RSS 859 MB against 922. So not the GL backend, not
memory, not module sharing, not the WASM tier-up, not the browser build. In real Google Chrome
the rate is 17.5 %, so a user solving a large model loses the tab: a product defect, not a test
artefact.
The decisive measurement: 60 sessions solving three times each — 180 solves — gave 7 crashes and
all seven were on the first solve; none of the ~113 with already-executed workers crashed. The
trigger is one worker's first execution coinciding with another's.
Fixed in
solver-pool.ts: while any reachable worker has never executed, the first job oneach runs one at a time on a distinct worker; once they have all executed once, dispatch is
simultaneous again.
About 88 ms once per session; the session median is unchanged and the steady state improves.
It removes the condition measured to trigger the crash — it does not repair the underlying fault,
which is in the WASM execution path or in V8, outside what this branch may touch. A regression
test asserts the invariant (no cold job is handed over while another is in flight, each warms a
different worker, and later solves overlap again) rather than a duration.
Two supporting fixes:
withCrashGuardmakes a crash fail immediately and name itself instead ofcosting 60 s and pointing at the wrong line, and
Viewport3Dnow releases its WebGL context onteardown as
RebarViewport3Dalways did. An M1 spec that M2 had broken was also corrected — itasserted on a generator row that no longer carries the arrangement.
No timeout was raised, no retry added, no test marked flaky, no WebGL disabled. Full detail in §9
of
docs/handoffs/m2-qa-handoff.md.Scope boundaries held
No changes to the solver, Rust, Cargo or the WASM sources.
StageSectionuntouched. No rebase.No snapshots updated and no timeouts inflated.
The three open findings, decided and closed
§6 of
docs/handoffs/m1-m2-audit.mdleft three product decisions open. All three are nowimplemented, one commit each, preceded by a defect the first of them uncovered. Full account,
including the CI audit and what is deliberately not closed:
docs/handoffs/m1-m2-ci-audit-and-three-decisions.md.0 · A section built through the modal had no geometry —
806e1289Measured before deleting anything, because removing the tab's inline form without this would have
replaced a working path with a broken one.
computeSectionPropertiesreturnstw,tf,tandtl. Thebuiltbranch oftoSectionFieldscopieda,iy,iz,j,b,handshapeand stopped —SectionFieldshad no thickness fields at all. For a catalogue pick that is right: the resolver finds the entry
by name and reads the published dimensions off the catalogue. A built section has no entry, so it
switches on
shapeand callsneed('b','h','tw','tf')against the section itself.Same profile, both paths:
toSectionFields)properties-only,missing: ['tw','tf']geometry-backedUndrawn, unextruded and outside every clause helper that dispatches on shape.
hollow-rect, thefirst template in the list, already fails it. Nothing caught it because the properties are
identical either way — area, inertia, mass and every solver result agreed, and only the geometry
differed.
1 · One catalogue for a profile, not two —
4a458b39ProSectionsTabgoes from 724 lines to 205. The family strip, the search box, the<tr onclick>table and the inline build form are gone; the modal trigger is the only way in.The inline path was the poorer by four measures a user could hit: it called
modelStore.addSectiondirectly and so produced a section with no arrangement, gap or rotation;its rows had no tab stop, no Enter and no role; it offered no standards body, design code or depth
filter; and the data sheet existed only on the modal.
Verified before deleting: every consumer of the shared class names inventoried (the E2E that
clicks
.profile-rowdrives/app/basic, which isSectionChanger); generators and the PRO tabreach the same
ProSectionModal; all fifteen families still produce a section with an area andwith
profileFamily, walked fromFAMILY_LISTrather than from a number written down; and nosolver, engine or calculation model is touched.
ColdFormedPanelis deliberately left alone. It creates sections too, but the parametric C/Zcatalogue is not one of the fifteen and has no modal equivalent — removing it would lose a
capability rather than a duplicate.
2 · The auxiliary calculation stops giving a verdict —
8e538631The tick was in two places, and finding the second is what made the first worth fixing:
conn-status-iconmappedokto✓on--st-ok, and the weld cardadded two more on its size range and its
L ≥ 4acondition;StageSectionpaintsdoneas a ✓ in--st-ok, and both sectionsreached
donethe moment a result object existed. Not when the result was good: when itexisted. A bolt group over its capacity turned its own header green, and so did pressing
Verify on the defaults, where Vu and Tu are 0 and the utilisation is 0 % because nothing was
asked of the joint.
Converted rather than deleted. The outcome is stated in words of its own —
within / near the limit / over the limit, never the canonicaladequate, whose Spanish is«cumple» and one word from the collision — there is no success tone in the mapping to reach for,
each card says what it is before it shows a number and names where the state that counts comes
from, and the sections are
optional, neverdone. §1, joint detection, keeps itsdonedeliberately: it means the detector ran, and says nothing about adequacy.
ProConnectionsTab's exemption from thesteel-never-verifiedsweep is gone.ProVerificationTabkeeps its own, and only it, for the reason it always had: that tab genuinely shares a component
with reinforced concrete.
3 · The section figure joins the token system —
4b0afd2bIt was the only metallic component with hardcoded colour. Each replacement was measured, and one
came out against the obvious answer:
#071322×2var(--st-bg)#071322vs--st-bgis 1.02#24486evar(--st-hair-strong)#566var(--st-text-2)--st-hairis the obvious reading and the wrong one: 1.48, below the literal's 1.74, so theframe would come out fainter than before. Inside the modal this figure also sits within a
--st-hairpreview well, and a nested frame in its parent's own token is one nobody sees.No new token:
tokens.cssis H1's. No snapshot was updated and none needed to be — the onlytwo committed baselines are
overlay-legendandbatch-dialog, both reinforced-concrete surfacesin
rc-design-visual.spec.ts, and neither renders this component.Gates
7977 unit tests and 14 build tests pass, typecheck is at its 473 baseline with no new errors, the
production build exits 0, i18n parity across es/en/pt holds with the seven new keys, and the full E2E suite is
800 passed / 5 failed / 1 flaky across 67 specs in 43.2 min. Every one of the five has a twin
on a clean
origin/maincheckout whose failure text is identical — see §19 ofdocs/handoffs/m1-m2-audit.md. Every run used a dedicated port.Scope, again
No solver, Rust, Cargo or WASM sources.
StageSectionitself untouched — only the stateProConnectionsTabhands it. No rebase, no force-push, no snapshot updated, no timeout inflated.The five E2E failures, classified against a clean main
Not inherited from the earlier audit. The control is a worktree of
origin/mainwithnode_modulessymlinked and the WASM artefact copied —engine/andpackage-lock.jsonareidentical between main and M2, so the only variable under test is the web source. That mattered:
M2 touched
nodes-instanced.tsandnode-scale.tsina47d6208, which is exactly what 3Dselection and
viewport-perfexercise.mainbasic-selection-permutations2Darmed kinds for elements+nodes, line 108basic-selection-permutations3Drc-design-visualoverlay legend696px → 697px, 645 pixels, ratio 0.03viewport-perf@perfkeyboard.up: Test timeout of 60000ms exceeded,orbitByKeyboard:211basic-demos:160The first three are identical character for character on both trees.
viewport-perfis not athreshold failure, which is what the earlier audit assumed: it wedges in
keyboard.up, which iswhy a different model fails each run — whichever one is running when it hangs.
basic-demos:160needed both conditions measured, and the isolated one was misleading:main--repeat-each=10, first-attempt failures--repeat-each=3, first-attempt failures#1every time#1twice,#2onceSo it is deterministic and session-dependent rather than a low-rate flake: alone it passes, run
after its siblings in the single browser session
workers: 1imposes it fails the first attemptevery time.
--retries=0cannot suppress it — the spec declarestest.describe.configure({ retries: 2 })itself. No timeout was relaxed and no snapshot updated.CI
e2e330084355724b0afd2b33008828740attempt 18e6cc46733008828740attempt 28e6cc467The diff between the two SHAs is one documentation file that enters no bundle, and attempt 1 failed
on
basic-demos:285— the same test that made #156 red on its duplicated sha. Together with #156'sown pair, that is three independent demonstrations that this suite produces both colours on
identical code.
gh pr checksshows only the last attempt.Every area the three corrections could have touched is green in the full local suite: section
selector 67/67, generators 55/55, materials 16/16, composition and battens 24/24, cold-formed C/Z
15/15, joints 49/49, connections 66/66, metallic workflow 58/58. The four failures under "3D" are
basic-selection-permutationsandviewport-perf; the metallic 3D specs pass.Why #156 looked red — diagnosed, and green again
Recorded here because the audit covering both branches lives on this PR, and because committing
anything to M1 would have moved it off the sha that just went green.
gh pr checks 156reported all seven green and exited 0 while the UI showed the PR as notpassing. Both were true. M1's head sha carries fourteen check runs across two
github-actions check suites; exactly one was red —
e2efrom run32921289231— andgh pr checksprints one row per check name showing the latest, so it never appeared there. M2'shead has a single suite. That, not the code, was the whole difference between the two PRs.
The PR timeline dates the duplicate:
base_ref_changedat 02:03:28 — #156 retargeted fromfeat/pro-steel-familytomain— then twopull_requestruns created at 02:03:30 and 02:03:31on the same head sha. The base change was legitimate. What turned it into two complete 28-minute
runs is that
ci.ymldeclares noconcurrency, so neither cancelled the other and one hit theflake.
A third suite from the
renderapp sitsqueuedwith zero check runs on M1, M2 and mainalike. It explains nothing; noted so it is not chased.
e2e(basic-demos:285)concurrency, triggered by a legitimate base changegh pr checksgreen with a live redrendersuite queuedmergeStateStatus: BLOCKEDmerge-treeclean against main and against M2Reproduced on a worktree of M1's exact sha:
basic-demos:285passes 3/3 in the repeated file, andthe full
--grep @smokesuite — precisely what CI runs — comes out 336 passed, 0 failed, 1flaky, 4 skipped, the same shape as CI's green run. It does not reproduce on darwin. The
mechanism is in the timing: 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 one go red rather than flaky:
:160declarestest.describe.configure({ retries: 2 })for exactly this reason and:285, structurally the samewalkthrough, has none. Not fixed here —
basic-demos.spec.tsis byte-identical between M1 andmain, 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
32921289231is green; allfourteen check runs are now success or skipped, both suites conclude success, and #156 stopped
showing red without a line moving.
concurrencywas deliberately not added toci.yml— it ismain's infrastructure, and
cancel-in-progresstrades a red for a cancelled run unless the groupis chosen deliberately. The patch is written down in §30 of
docs/handoffs/m1-m2-ci-audit-and-three-decisions.mdfor whoever owns it.The two findings that were left open, audited to their cause
Part III of
docs/handoffs/m1-m2-ci-audit-and-three-decisions.mdleft.conn-ratio-badgeand thesvelte-checksilence open, with evidence and no diagnosis. Part VI closes both. Neither is whatthe earlier reading said it was.
.conn-ratio-badgeis dead CSS, and M1 is what killed it. The census is 0 applications of theclass in
main, in M1, in the merge and here — all four occurrences sit inside<style>. Theearlier account blamed main's
2c79ed52and the union merge, which explains how the rules cameback, not how they died:
git log -S 'class="conn-ratio-badge'puts that onb71432cd, M1'sown sub-section split, two days earlier, which moved the governing ratio to
StageSection's neutralbadgeprop and deleted both spans along withstatusClass().851fd57bthen measured thecontrast of those rules and rewrote all three states, and five assertions pinned the result — a
commit and a test suite about CSS the browser never painted. So there is no product disagreement to
settle. Removed in
6448e89d, on M1, in a commit of its own, and merged down here; the ratio itselfis untouched and still printed by both sub-sections. Three hand-written guards replace the five
assertions: no
conn-ratio-badgein the stylesheet, the percentage still reachesStageSection,and no element on the panel carries
st-ok/st-warn/st-fail— so a ratio gets no status hueat all and the auxiliary block's own
within / near the limit / over the limitstays the only thingentitled to comment on it. No green tick and no normative verdict came back.
svelte-checkdoes readProConnectionsTab.svelte. An injected type error is reported at2:9in the same run where an injected dead selector is not. The blind spot is upstream of thechecker: Svelte's
css-prunestops pruning as soon as a component has a class attribute it cannotread statically, and this one has four (
class="conn-result-card {auxTone(...)}"). Minimal repro:<div class="a">warns about an unused.zz-dead,<div class="a {x}">does not. Measured over thewhole tree by appending a dead selector to every styled component and compiling: 30 of 169 are
blind the same way, 24 of them under
components/pro/. The 116Unused CSS selectorwarnings inthe same run are the mechanism working where it can.
No CI job was added, and the reason is not cost. A
svelte-checkjob over9883e2bdwould haveprinted exactly the 0 mentions the manual run did — it would not have found the first item. The
separate gap from Part I §2 is real and is now costed rather than acted on:
check:gaterunsend-to-end in 14.4 s, every metallic path is at 0 errors, so extending
GUARDED_PATHSandadding one step to the existing
webjob is free. Proposed with files, impact and tests in §32 andleft for whoever owns
ci.yml, on the same grounds as theconcurrencypatch in §30. What no jobfixes, and what is documented as a standing limitation: dead CSS on those 30 components is findable
only by a manual census, or by an assertion written by hand.
Part VII — the designed joints reach a share link (I-08), and A-01 becomes a decision
The base moved
origin/main(b579de87) went into M1 by explicit merge, and M1 went into this branch the same way. No rebase, no force-push, no conflicts either time. The two branches now share the same base ofmain, and this PR stays based onfeat/pro-steel-m1.M1 also carries one E2E fix that this branch inherits:
basic-selection-permutations.spec.tsreadaria-checkedwhereSelectionPanelpublishesaria-pressed, so its fifteen-permutation audit had been failing on the first two-kind subset from the start — and had never run anywhere, because CI'se2ejob runs--grep @smokeand that file carries no tag. Details in #156.I-08, the joints half — closed
Part VI put the joint choices on
StructureModel.jointDesigns, so they travel.ded, undo/redo, tab capture and autosave. The URL codec was not one of those paths: it is a separate versioned wire format that enumerates what it carries. A shed with twenty designed joints, shared by link, arrived with twenty undesigned ones — and the worst of the three surfaces that build such a link is the feedback widget, which attaches one to every report. A user reporting a problem with a joint sent a link that rebuilt the model without it.The format
One new top-level key of the compact object, and no position added to any existing tuple:
Four fixed slots per joint; all growth room is inside
choices, which holds four groups (b,p,w,ba) of one- and two-letter keys. The length discipline is the section tuple's, becauseMAX_URL_SAFEis a limitToolbarProjectactually checks.SHARE_VERSION4 → 5, compatible in both directions:jd, which reads as "no joint decisions". Not an empty joint designed at every node.sv >= 3andsv >= 4, which 5 satisfies, and a reader that predatesjdignores a key it does not know.choicesare ignored, so a field added later as an optional key does not make today's build reject tomorrow's link.Only the choices, and only what can be vouched for
packJointDesignswalks a field table and emits nothing else. AcapacityKN, autilisationor achecksarray that reached the model through a hand-edited file cannot reach a URL through it — asserted by tampering with a model on purpose and grepping the wire. Capacities are recomputed from the open model on every read.Obsolescence is not carried.
nodeMissing/nodeMoved/topologyChangedare decided against the model that is open, on every read; they are derived, so they are not part of the persisted decision. The fingerprint they are derived from is —atMmandmemberCount— because that records the joint the user was actually looking at.Carrying it is what extends I-07 to the link. A link opened over a different model reports its joints obsolete, with a reason and two remedies, instead of matching them by node id and presenting as chosen something nobody chose there. A payload that arrives with no fingerprint reconciles as
nodeMoved— never as a match.Refused rather than half believed
A known key with the wrong type, an entry that is not a well-formed tuple, or a container version this build cannot read all throw, and
decompressV2turns that into thenullits callers already handle.Rejecting the whole link rather than dropping the joints is deliberate, and the cost is written down rather than hidden: the model bytes and the joint bytes arrive in one deflate stream, and treating half of a stream that is not what it claims to be as trustworthy is the weaker position. What it costs a recipient is stated in
share-codec-fields.md§7.3, including that the#data=path answers a rejected link by opening empty — the same silence any corrupt link already gets, and not this change's to redesign.The drift guard — the piece the handoff asked for in §6 and never had
Four section fields went missing from this codec without anyone noticing, because nothing failed when a field was added and the codec did not hear about it. Two halves now, and both were verified by breaking them on purpose:
JointChoices→npm run typecheckreports exactly one new error, pointing at theRequired<…>fixture that no longer lists every key.The permitted-value lists are built from
Record<Union, 1>, so adding a member toThreadCondition,EdgeFinish,Exposure,WeldProcessorWeldLoadingdoes not compile until the wire accepts it.BOLT_GRADESandBUILT_UP_ARRANGEMENTSare imported: Tabla J.3.2 and the seven arrangements have one list each, in one file each. The same pattern is written up forinterface Sectionto copy.Measured, not assumed
Twenty fully designed joints — bolts, plate and weld on every one — cost 391 characters, 19.6 each: the first 203 and each one after it about 10, because the key names repeat identically and that is what deflate collapses. The test's budget is 30, half again over the measurement.
Tests
55 new, against the code that runs. The codec is its own pure module precisely so they can be:
url-sharing.tsimports four stores, which is whyurl-sharing.test.tstests an inlined copy of the encoder, and a wire format tested through a duplicate is a wire format with no test.Round-trips for a model with no joints, a bolted joint, a weld, battens, several nodes and every bolt grade the strength table publishes; absent optional fields staying absent rather than arriving as zero; an explicit
nulltold apart from a key never set; nineteen corrupt payloads refused; exact reconstruction field for field; nothing computed on the wire; a joint refusing to follow a link into another model, with each of the three obsolescence reasons; an oldsv:4link still opening and opening with no joint decisions; the M1↔M2 direction asserted as the property that makes it true — exactly one new top-level key, everything else byte-identical; and the length budget.The four section fields of I-08 are untouched and still open. They are preexisting, they belong to concrete and steel alike, and
built-section-contract.test.tsstill asserts their loss.A-01 — explained, not decided
A-01 is the one inventory item that is a product decision rather than a defect, and the inventory ranks it third by priority: not because anything is broken, but because reverting it after a merge costs far more than reverting it now.
It is written up in product language in
docs/handoffs/a-01-decision.md— what the user sees now against what they saw onmain, which of the three withdrawn ways in was equivalent and which was not, whether data is lost, whether QA sees it, whether it blocks the merge, the minimum correction, and four alternatives with what each costs. Nothing was implemented: it changes visible behaviour, so it stays a pending decision.The findings, since they are the part that decides it:
tw,tf,tandtl— was fixed before anything was deleted.addSectiondirectly, so a section entered with no arrangement, no gap, no rotation and noProfileSpec; its rows were<tr onclick>, unreachable by keyboard; and it offered no standards body, design code or depth filter, so the catalogue looked smaller than it is.builtrecord, plus aninitialthat lets a built section be reopened and edited, plus the only tests of the pair.Gates
typecheck 473 = baseline 473, no new errors · unit 8103 pass / 0 fail (437 files, 12 skipped, 1 todo) · build project 19 pass ·
npm run build✓ · i18n parity ✓ (locale-parity,steel-keys,pro-flow-coverage) · full local E2E on its ownE2E_PORT.No Rust, Cargo, WASM or solver change. No snapshot updated and no timeout inflated. No new PR opened. Both PRs stay draft.
Review summary — scope, limits, and what is deferred
Delivered scope
Everything in Parts I–VII above. In one list:
within / near the limit / over the limit), deliberately distinct from the canonical one.StructureModel.jointDesigns, so they travel.ded, undo/redo, tab capture and autosave.SHARE_VERSION = 5, choices only, with the drift guard the handoff asked for and never had.Merge order, and the dependency — read this first
#156merges intomainfirst.#164merges second.This branch is based on
feat/pro-steel-m1and contains it by merge. It is not independent of #156 and must not be retargeted atmainto merge earlier: doing so would carry M1's entire diff in under this PR's review. The stack is M1 → M2, in that order, and the base field of this PR is the record of it.Known limitations
steelCountsAsVerified()returns the literalfalsecomposition,profileFamily,tl,built. Preexisting, concrete and steel alike. The joints half is done; this half is notshare-codec-fields.md§1–§6; asserted as a declared loss bybuilt-section-contract.test.ts#data=path a rejected link opens empty without saying why, which is the silence any corrupt link already getsshare-codec-fields.md§7.3designedNodeIds, so a document never tabulates onegenerator.assume.latticeBasesPinnedNoOutOfPlanedocs/handoffs/m1-m2-qa-manual-inventory.mdis the inventory as of the start of review, not as of this head. Six entries were re-measured after it was written; the corrected file is onbackup/qa-inventory-remeasure-2026-08-27and is deferred to M3 (see the M3 table). Reading an entry that contradicts the code means reading the snapshot, not a defect in the codeA-01 — the one open decision, and it is not a blocker
docs/handoffs/a-01-decision.mdstates it in product terms. Summary of the findings, because they are what decides it:tw,tf,tandtl— was fixed before anything was deleted.addSectiondirectly, so a section entered with no arrangement, no gap, no rotation and noProfileSpec; its rows were<tr onclick>, unreachable by keyboard; and it offered no standards body, design code or depth filter.initial, so a built section can be reopened and edited, and it is the only one of the pair with tests.Classified as apt for M3, with one caveat stated rather than buried: it is not a correctness error, not data loss, not a false claim and not a broken flow — so it does not block. But the cost of changing it grows after merge, because the tab went from 724 lines to 205 and three regression tests now assert the absence of the machinery. Deciding it before merge is cheaper than deciding it after; not deciding it does not make the merge unsafe.
Non-blocking findings, for M3
docs/handoffs/m1-m2-qa-manual-inventory.mdon the branchbackup/qa-inventory-remeasure-2026-08-27, 3464 lines against this branch's 3205. Four entries in the shipped version state the OPPOSITE of what this branch does (B-01, I-06, I-07, I-08), plus B-13, and the corrected version adds E-25, the obsolete-joints notice — the visible half of I-07's repair, which did not exist when the inventory was writtene2eis red, and that fell when the job was relaunched and passed.dedwrites. Whoever next incrementsJOINT_DESIGNS_SCHEMA_VERSIONfor a.dedreason makes every already-shared link stop opening entirely — not "without its joints" — because §7.3 rejects the whole link on a version it cannot readshare-codec-fields.md§7.1basic-demossection-walkthrough flake — narrowed to two candidate causes, not diagnosed.main's0844f01dalready carries containment (declared retries) and calls itself "containment, not a diagnosis"mainmerge — so the test can still redden this job until thenreturns a mechanism wearing a number under a load along the buildingis 2.89 s against the 5 s defaulttypecheckjob, nosvelte-checkjob, noconcurrency, ande2eruns--grep @smokeonlyci.yml. The@smokegap is what hid a spec that had been broken from the startcss-prunestops pruning once a component has a class attribute it cannot read staticallymainor underrun-e2eInstall wasm-packreports success when its download fails. The step is `curl …, so its exit status issh's — a failedcurlleaves the step green and the failure resurfaces two steps later aswasm-pack: command not found, exit 127, with the blame onBuild WASM engine. Observed on this PR:curl: (35) Recv failure: Connection reset by peer`@perfviewport measurements time out under whole-suite contention, 4/4 in isolation@perfNothing classified as blocking
No correctness error, no data loss, no broken main flow, and no false claim left standing. One item did fall in the false-claim category and is retracted in #156: I published a diagnosis of the walkthrough flake that said the test skips opening the Section Analysis panel. It is wrong —
stressQueryis written by the viewport click handler whenfindNearestElement(…, 0.3)finds a member, and the panel opens as a consequence of that write, not as a precondition. The finding is narrowed, not diagnosed.Gates on this head
typechecktest:unittest:buildnpm run buildlocale-parity,steel-keys,pro-flow-coveragelint·test·suite (1)·suite (2)·web·e2ehttp://127.0.0.1:4004, HTTP 200, serving this branch with M1 + M2mainhas moved on since M1 synced with it (b579de87→c4540883). Deliberately not chased — the branch is frozen for review. A dry-run merge of the newmaininto M1 produces zero conflict markers; the single file both sides touched,basic-demos.spec.ts, is changed in different regions.No Rust, Cargo, WASM or solver change. No snapshot updated, no timeout inflated, no new PR opened. Both PRs stay draft.