M1 — Metallic PRO: material and section selectors, section properties with a declared basis, and the shed's longitudinal load path - #156
Open
Batuis wants to merge 37 commits into
Open
M1 — Metallic PRO: material and section selectors, section properties with a declared basis, and the shed's longitudinal load path#156Batuis wants to merge 37 commits into
Batuis wants to merge 37 commits into
Conversation
PR21 left every `lookupGrade` call site passing `undefined`, with a comment saying PR #132's grade catalogue "is not on this branch". It is: the merge that brought `structural-grades.ts` and `non-metal-grades.ts` into main (`d1ba4fb2`) is an ancestor of this branch's base. So the lookup is supplied, and the family of a metallic member stops being a guess about the magnitude of `fy` and becomes a reading of what the project recorded. `grade-family.ts` is the implementation of the seam, not a replacement for it: `material-family.ts` still works with no catalogue at all, which is what keeps its tests free of one. The non-metals are resolved too, because the material picker writes a `gradeId` for concrete and timber as well and a lookup that only knew the metals would file a 60 MPa timber class as concrete. Two consequences worth stating rather than leaving to be discovered: · the panel's "family was deduced" warning disappears for a member whose grade is declared, and stays for every model saved before the picker carried one. Both are pinned. · aluminium separates from steel for the first time. The inference could not tell them apart and filed 6082-T6 under steel; the declaration can, so an aluminium member now falls out of a list whose admission rule is `isSteel`. Dropping rows silently is the failure this file was written against, so the inventory says it: a new notice names the non-ferrous members, and a model whose only metal is aluminium reports `nonFerrousOnly` instead of the false "none of them is metallic". Nothing about verification moves. `steelCountsAsVerified` still returns the literal `false`, the four states are unchanged, and a bound authority with demands present still yields NOT_DESIGNED. The concrete baseline digest is unchanged at 1bd4d9c1d575b085. `member-context.ts` and `auto-verify.ts` could take the same lookup and the same shared ceiling constant. Both are shared files under the overlap protocol, so they are reported for coordination rather than edited here.
… came from `PROFILE_FAMILIES.L` is `[...L, ...IRAM_L]` — the EN 10056-1 equal-leg angles plus the Argentine series tabulated for CIRSOC 301-EL — while `FAMILY_CLASSIFICATION.L` names EN 10056-1 for the whole family. Eleven of the rows in that group are IRAM-IAS profiles filed under a European standard, and PR21 recorded it as an inherited gap it would not close. The provenance is read from WHICH SOURCE ARRAY a row came from, which is a fact the tables carry and the merged array loses. `IRAM_L` is imported and its ids get `IRAM-IAS U 500-558`, the standard its own header names; everything else in the family keeps the European one. Nothing is parsed out of a name and nothing is transcribed, so moving a profile between those two files moves the answer. The family is reported as carrying two standards rather than one of them being chosen to stand for both: `standardsInFamily` derives the list from the rows, the picker's group header shows the count with the designations in its tooltip instead of printing a designation that is false for eleven rows, and the rows whose standard is not their family's carry their publishing body as a tag. Filtering by body now works on the row — asking for IRAM-IAS angles used to return nothing. `section-catalog.ts` is NOT edited. Basic's own section picker reads `FAMILY_CLASSIFICATION`, so under the overlap protocol the single-standard family declaration there is reported for coordination rather than changed. This adds the per-row detail the merge threw away; it contradicts nothing. Two test holes closed while here, both in the metallic surface's own i18n gate: Portuguese key parity was checked by nothing, and the `steel.panel.empty.*` keys were listed by hand — so the fourth empty reason added by the previous commit would have shipped rendering its own key. Both are now expanded from the unions they come from.
…not derive A PRO section card is expected to show section moduli and radii of gyration — what a member is actually sized with — and the tables publish neither. One of them is exact arithmetic on published numbers and the other is not, and the difference is the whole design of this module. `sqrt(I/A)` needs only two tabulated values, so a radius of gyration is always available; it reproduces the published 8.26 cm for an IPE 200 and satisfies the same identity the tube tables were validated against. A section modulus needs `I/c`, and `c` — the distance from the centroid to the extreme fibre — is not in any table. Half the depth is the right answer only where symmetry puts the centroid at the centre of the box. A UPN 200's centroid sits 20.1 mm from the back of its web, so half the width gives Wz = 39.5 cm³ where the governing value is 27.0: a 46 % overstatement that looks entirely plausible. So `c` comes from `resolveProfile`, which builds the verified canonical outline and returns the bounding box measured from the centroid, and every quantity carries its basis: tabulated, derived from the table, derived from the outline, or unavailable with a reason. Where the section is asymmetric about the axis in question the MINIMUM modulus is reported, with a note saying it is one of two. Where nothing can support a value — MC's weak axis, whose flange taper no outline can fit — the row says so rather than showing a number. The torsion constant is not derived. It is tabulated for the IRAM tubes and absent everywhere else, and the standing prohibition is that a polygon-derived value is not J for a thin open section. The root radius turned out to mean four different things, and flattening them would have misreported three: tabulated (IPE), solved from the published clear web depth (W, C — an inversion, not a column), fixed by the standard as a rule on the profile's own dimensions (IPN, UPN, the tubes — nothing missing, the outline is exact), and the three C9 profiles that carry `r: 0`, which `iram-c.ts` uses to mean "no usable radius, drawn sharp-cornered". A zero shown as a tabulated value attributes our drawing decision to the standard. Each case is decided from the family's declared geometry fidelity, not from a hand-kept list, and each has its own sentence in all three languages.
`structural-grades.ts` is a strong database — it separates the product standard from the design code, records the thickness bands and the standard that tabulates them, and marks which values were read from the governing standard. What it has no seam for is asking questions of it: every surface that wants grades rebuilds filter-by-family, filter-by-region, filter-by-code and search inline, which is how two pickers come to disagree about which grades exist. `lib/grades/catalogue.ts` gives it the shape `lib/profiles/catalogue.ts` gives the profile tables: query in, entries out, behind a `GradeSource` a project library could implement. The code filter is delegated to `gradesForCode` rather than reimplemented — including its decision to return the whole family when a code matches nothing, because an empty picker looks broken — and it is ignored rather than guessed at when more than one family is selected, since a design code covers exactly one. The card states the authority behind every number, which is the part a PRO user needs and Basic's picker cannot show. Four authorities appear on one card: values published in the product standard; the strengths of the 45 grades the source marks as typical of the alloy rather than read from the governing table; the shear modulus, derived through G = E/2(1+nu) and labelled as derived — it comes out at 80 769 MPa for the EN steels, which is the 81 000 CIRSOC 301 chapter 2 quotes, an independent check that the identity is the right one; and the thickness bands, always shown with the design code that tabulated them and never with the product standard beside them unqualified. The pairing verdict is delegated to `isUnusualPairing`, whose three-valued answer becomes a named verdict so a component never interprets `null`. It is a note about cost and lead time and nothing is blocked by it. Basic's `MaterialPresetSelector` is deliberately not reused: it is a modal over the whole app, it offers concrete and timber, it has no keyboard path and no region axis, and its only provenance mark is a `~`. The component is a panel, metals only, and it says above the list that a selection configures the model and verifies nothing — because there is no metallic authority here to verify anything.
…surement that found it missing
PR21 solved the generated shed under a vertical load and stopped there. Under a
load ALONG the building the same shed returns 2.4·10^11 m for 20 kN — not a
singular matrix, so every `isFinite` check on it passes. It is the exact failure
mode `pr21-lattice-cap-idealisation.md` §7 was written about, in a direction
nobody had loaded.
The instrument is the one that file established: add one class of restraint at a
time and see which removes the freedom. It answers at once — restraining
translation along the building at every node ABOVE the eaves takes the response
to exactly zero, while restraining the eave line itself changes nothing. So the
free body is the roof, and the reason is structural rather than incidental: a
planar truss with a pin-jointed web has no out-of-plane stiffness, so the whole
top chord, tied frame to frame by the purlins, translates sideways as one piece.
Three members make the path, and none of them makes it alone:
roof plane → vertical bracing between trusses → eave line
→ eave beams → braced wall → ground
so there are three switches, not one. Each one's contribution is measured by
removing it: roof bracing alone leaves 1.7·10^11 m (a triangulated plate that
still slides — the obvious wrong fix, and now a test); vertical bracing without
a braced wall leaves 1.9 m (the tie reaches an eave line held by nothing but the
columns' weak-axis bending); the full system reaches 4.4 mm, and 2.3 mm with
every bay braced. Eleven orders of magnitude is what separates a load path from
a stiffer mechanism.
`purlins: false` is answered too, and answered in both halves. Bracing only the
end bays leaves the interior frames with nothing holding them sideways, so it
stays a mechanism; bracing EVERY bay supplies the restraint the purlins supplied
and it solves vertically at 4.8 mm — which is a statement about which restraint
was missing, not a suggestion that a roof can do without purlins. The
`roofWithoutPurlins` disclosure stays on the model whatever the bracing, because
it is about the purlins and not about the stiffness.
The braces land only on steel that is already there. `findNode` cannot create a
node, because a diagonal drawn to a computed position would leave a node held by
two bars and nothing else — a free node introduced by the member meant to remove
one — and a test asserts the node count is unchanged in every configuration, so
a silently skipped brace fails rather than shipping a wall with no brace in it.
All three default to false, so the shed a user gets by pressing Generate is the
one PR21 measured. No solver, Rust, WASM or Cargo change: the mechanism was in
the geometry handed to the solver, and the solver was right about it both times.
`GeneratorMaterial.gradeId` has existed since PR21 and had never been given a value. A generated model took `PLACEHOLDER_STEEL` — A36, which is not an Argentine grade — and declared `generator.assume.placeholderGrade` to say so. The handoff recorded that filling it in was a one-line change at the call site, and this is the call site. The grade is chosen once and applies to every generated member, because a frame is fabricated from one steel and a per-role material would be a capability the generator has no use for. What IS per role is the consequence: the roles do not share a section family — a shed's chords are I-sections and its diagonals are angles — so the pairing question has one answer each, and the panel names the roles whose profiles that steel is not ordinarily rolled in. Cost and lead time, never correctness, and nothing is blocked. With no grade chosen the control says what the model gets instead, rather than leaving a user to conclude the members have no material at all. Three things are pinned: the declaration reaches the emitted material, the placeholder disclosure disappears exactly when it stops being true, and a grade moves no node, section or member — it is one row in one table. The headline `fy` is used, not a band value. A generator places profiles and does not size them, so the member's governing thickness is not known here and resolving a band would be inventing the decision that picks one.
…ows the full card PR21 replaced a 100-option `<select>` with a searchable popover and said the general PRO picker would need more axes. These are them, and each one is a question an engineer asks out loud: which body publishes these dimensions — a project working to IRAM tables does not want the DIN series mixed in; which design code, delegated to `familiesForCode` rather than reimplemented; and what depth range fits, which is the constraint a drawing actually imposes. The card is what makes it a PRO control rather than a longer list. A section is chosen on the moduli and the radii of gyration, which the tables do not publish, so every row carries the basis of its number — tabulated, derived from the table, derived from the verified outline, or unavailable with the reason. A modulus from a real centroid and one from half a bounding box are different claims and only one of them exists here. What the source does not give is a block with sentences in it, not three blank rows. Comparison holds up to three pinned profiles, by id, so a pin survives a filter change: comparing an IPE with an HEA means having both in view while the search shows one. The pin is its own control — clicking a row picks and closes, and that primary action must not become ambiguous — and the table gets its own horizontal scroller so the panel body never scrolls sideways at 1280×720. Two defects fixed while here, both mine from earlier in this branch: a local binding named `props` collides with the `$props()` rune and surfaces as a circular-type cascade rather than a name clash, and the depth boxes were typed as strings while `bind:value` on a number input writes a number or null — `.trim()` on that throws at the first keystroke. The empty state now names every filter that can produce it, including a half-typed depth. PR21's own message predates these axes and its key lives in the shared main dictionary, so M1 adds its own in the steel namespace rather than editing a file H1 may be holding.
… the joints gate covers all three languages Two closures, both about disclosure rather than calculation. The member table showed a material NAME, which a user can rename to anything. It now shows the catalogued grade with the product standard beside it, because two standards can give the same designation to different steels and a designation on its own is not a specification. Where the project declared nothing the cell says which of the two reasons applies — no grade recorded, or a grade the catalogue no longer knows — instead of being blank. The inventory carries only the id, since it is pure by contract, and the panel resolves it through the grade source. The joints panel's 77 `conn.*` strings live in the main dictionaries rather than the steel namespace, so the parity gate never saw them: the five limitation disclosures, in three languages, verified by nothing. They are checked now — key sets identical across es/en/pt, every facet of every limitation present, with the ids read off the panel source so a sixth limitation added without its five sentences fails here instead of rendering its own keys. The rule that a warning affecting a result sits beside the result is asserted by position too. PR21's never-verified guard caught one of my own strings: a note describing the canonical outline as "verified" tripped a check that exists to stop the word appearing anywhere metallic except as a denial. The guard is right and the wording was lazy — it now says what verification meant there, that the outline's area and inertias reproduce the published ones. The joints panel needs no behavioural change from M1. Its metallic filter reads the same inventory verdict, so wiring the grade catalogue made it stricter for free: an aluminium member is no longer offered a bolt group. One consequence is recorded for coordination rather than edited — `conn.gap.aluminium.scope` says the metallic inventory still lists aluminium members, which stopped being true this branch. The string lives in a shared file; the correction is proposed in the M1 handoff.
… overlaps left for coordination Records the four things a reader of this branch needs and cannot reconstruct: the verification of where M1 starts, the measurement that shows PR #151 carries no selectors and #132 does, the eleven-orders-of-magnitude table behind the longitudinal load path, and the four shared-file items reported rather than edited. Also records what M1 leaves open, including one correction it owes to a document it did not edit: §8 of the lattice-cap handoff still says the cause of `purlins: false` is not demonstrated, and the comment `606ca6e4` left in `shed.ts` demonstrates it. That file belongs to PR21, which is in review.
…promised, and the audit that replaces two shared-file edits
## The four shared-file points
`docs/handoffs/m1-h1-coordination.md` documents each with the eight answers the
overlap protocol asks for: problem, files and lines, proposed change, owner,
impact on the other branch, tests required, safe alternative, and who should
implement it. None is implemented; none of those files is touched.
Owners proposed: the duplicated `CONCRETE_FY_CEILING` and the `profileSelector.*`
consolidation belong to a common integration; passing the catalogue to
`member-context` belongs to H1, because it can change which members get designed
and the concrete pipeline is not M1's domain; correcting
`conn.gap.aluminium.scope` belongs to M1 with authorisation, because M1's own
change is what made the sentence false and Uniones metálicas is M1's phase.
Two of the four have safe alternatives M1 can do without coordinating, and both
are implemented here instead of the shared edit:
· the threshold that exists twice is now tied by a test that reads the local
copy through `rcCheckability` and requires it to agree with the exported one
across the whole range. With one constant it is a tautology; with two it is
the assertion that makes a future divergence red instead of silent.
· PR21's seven picker keys live in the main dictionaries, which the steel i18n
gate does not read — so they were covered by nothing. Covered now, by reading
those files without editing them, the same way M1 covered the 77 `conn.*`
keys that were in the same position.
## The document PR21 pointed at
§8 of `pr21-lattice-cap-idealisation.md` says the cause of `purlins: false` is
not demonstrated and refers the reader to "the document of that investigation".
That document does not exist — I searched `docs/` — and the claim was already
contradicted by PR21's own later commit `606ca6e4`, whose comment in `shed.ts`
measures it.
`docs/handoffs/m1-purlins-false-investigation.md` is that document, with each
half attributed to whoever measured it: PR21's restraint experiment identifying
out-of-plane translation, and M1's finding that the default shed has no
longitudinal load path at all. It does not edit PR21's document — that belongs to
#135 — it supplies what the reference pointed at.
It also corrects an argument of §7 of `pr21-integration.md` with measurements
rather than with an opinion: generating the longitudinal bracing was supposed to
justify pinned lattice bases again. It does not. Pinned bases already solved
vertically before any bracing — that is the cap idealisation, documented in §4.1
of the cap handoff — and under longitudinal load neither the wall bracing nor the
roof bracing helps, because the missing member was the vertical bracing between
trusses. Four tests pin it.
## The metallic surface, audited across its modules
`steel-never-verified.test.ts` enumerated five component paths by hand and M1 had
added four components to those same directories, none of them covered. It now
walks the directories, so the newest screen — the one most likely to break the
commitment — is covered by default, and the audit is widened: no passing tone, no
bare tick outside the joints panel, and no state named that the engine cannot
produce.
`steel-surface-audit.test.ts` checks what only exists across modules: the five
joint limitations are still those five, pinned by name so a swap cannot pass; the
set of authorities behind every displayed number is closed; a picker never imports
a table, so both catalogues stay pointable at a project library; and provenance
resolves everywhere — every standard published, every band attributed to a design
code and never to the product standard, every id the model can store resolvable
in both catalogues.
Source untouched this turn: tests and documentation only.
…n, and an executable QA checklist Source untouched: tests and documentation only. None of the four shared-file points is implemented, and none of those files is touched. ## Point 3 — prepared, not applied H1 is adding `design.floor.state.*` to the same three dictionaries, so the one-line fix waits. What is ready: `docs/handoffs/patches/conn-gap-aluminium-scope.md` carries the three diffs, the location BY KEY rather than by line — in `pt.ts` the key sits in a different region of the file, so a line-numbered patch would not apply — the impact, and the apply procedure. `conn-aluminium-scope.test.ts` (10 tests) pins the behaviour that makes the sentence false, checks the proposed text against the properties it has to have in all three languages including the never-verified rule, and asserts the shipped state so the wait is visible. Applying the patch inverts ONE assertion, marked in the file. Shadowing the key from the steel namespace stays rejected: the merge order means it would win, and H1's edit to the same key would vanish without an error. ## Point 2 — the observation the decision needs `member-context-lookup-observation.test.ts` (9 tests) injects the lookup at its own call, so nothing shared is touched and every assertion stays true before and after H1 wires the call site. The metallic half is a no-op, as the point said. What the wiring also fixes is not metallic, and is why H1 should read it: **every catalogued timber class is currently handed to the concrete pipeline.** EN 338 runs C16 to D60, all at or under the 80 MPa ceiling, so the inference answers `concrete` and a C24 beam is designed as 24 MPa concrete. With the lookup the declared grade says timber and it is excluded. Two smaller findings for whoever is in the file: `ContextModelData.materials` does not declare `gradeId`, and `MemberContext` keeps `verdict.family` while discarding the basis, so a concrete surface cannot say whether a family was declared or guessed — the distinction the metallic panel shows. An earlier draft of that file read a `familyBasis` that does not exist and passed on `undefined`. It now asserts the absence deliberately, because a vacuous assertion is worse than none. ## QA, the five gaps, and the pinned-bases question `m1-qa-checklist.md` is executable against 127.0.0.1:4004 and starts by verifying which worktree that port serves — the trap that already cost a session on 4173. It covers materials, grades, profiles, provenance, the three generators, purlins and bracing, joints, the five limitations, states, the three languages, 1280×720 and accessibility, with the numbers to expect where a wrong one would still look plausible: Wz between 20 and 30 cm³ for a UPN 200, because 39 means half the width was used. It closes with what automation already covers, so nothing is re-tested by hand, and with the four findings that are not bugs. The five joint limitations now have a canonical table outside the app, with the `affects` value each — the field that separates a limit state nobody computes from a number that exists and is not drawn. Pinned lattice bases are recorded as an M2 candidate with the four things a reopening would need, and explicitly out of M1's scope: no new investigation, no change to the current idealisation, no lateral hypothesis without a measurement.
…e checklist half that is now automated
Source untouched: an e2e spec, a test comment, and documentation.
## The contract H1 asked for
`m1-grade-family-contract.md` states `catalogueGradeFamily` definitively:
location, signature, inputs, outputs, consumers, and what happens when `gradeId`
is missing — which is the part that matters, because the answer is "nothing
changes". Passing the lookup is additive, not a migration: a model with no
declared grade, or one declaring a grade the catalogue no longer knows, keeps the
`fy` inference exactly as today.
`null` is the load-bearing part of the return type. It does not mean `unknown`,
it means this catalogue cannot answer, and the caller must fall back. Returning
`unknown` would report a material with a plain strength as unclassifiable.
The counts in the table are read out of the arrays rather than estimated — 27 / 20
/ 11 / 10 metallic, 25 concrete, 13 timber; my first draft had three of them
wrong. The one row that moves when H1 wires the call site is timber, and it moves
toward correct: every EN 338 class sits under the 80 MPa ceiling, so a C24 beam is
designed as 24 MPa concrete today.
Two neighbourhood findings for whoever is in the file: `ContextModelData` does not
declare `gradeId`, and `MemberContext` discards the family's basis. M1 does not
add either — shared file.
M1 commits to not changing the module without coordinating.
## M2, with what each item needs
`m2-candidates.md` covers the five: plastic modulus, closed-section torsion,
comparison highlighting, bracing sizing, more languages. Each says what is
needed, what must NOT be done, and the acceptance criterion. None is started.
The useful part is why each one is not trivial. `Wpl` needs the plastic neutral
axis, not the centroid, and they differ for every asymmetric section — but EN
10365 tabulates it for 56 profiles, which is the validation route. Bredt has the
same shape: 100+ IRAM tubes publish `j`. Comparison highlighting is blocked on a
product decision, not code: "better" is not a property of a row — more mass is
worse, and the governing radius of gyration is the weak-axis one. Bracing sizing
is blocked on a demand that does not exist and an authority that does not exist,
with slenderness as the honest intermediate step. And within the languages,
translate the limitations before the labels: an English label is a nuisance, an
English limitation is a risk.
## The checklist, half of it automated
`m1-steel-selectors.spec.ts` — 25 tests, each naming the checklist item it
discharges — covers the mechanical half through the click path a user has: the
two-standard angle group, body filtering on the row, the depth bounds, the basis
badges as text, the pinned comparison surviving a filter, the band standard, the
typical mark, the bracing notice, the grade column, the three languages, and
1280x720 with no sideways scroll.
Writing it found two things the checklist claimed that the app does not do, and
both were mine:
· a half-typed depth does NOT empty the list, because `input[type=number]`
refuses a non-numeric keystroke — Playwright fails outright on `fill('-')`.
The NaN branch in `queryProfiles` is defensive, not reachable from that
control, and the unit test now says so instead of implying a UI path.
· G renders as `81 GPa`, not `80 769 MPa`: the card switches to GPa above
10 000 MPa. The value was right, the unit in the checklist was not.
Both corrected, and recorded in the checklist as corrections so a reviewer does
not go looking for the wrong number. The checklist now also states what stays
strictly manual: whether things fit, whether a notice lands at the right moment,
and whether the three languages read like a person wrote them.
…tory lists those members Applied now that H1 published `ad4192e6`, which was the condition. Their commit adds `design.floor.state.*` as one contiguous block around line 5961 of each dictionary and touches no `conn.*` key, so the two changes never meet — and their own note in that block anticipates this one and asks that it not be interleaved with theirs. It is not: this is a value replacement of an existing key near line 4341, not an insertion. Applied BY KEY NAME, never by line number. In `pt.ts` that key lives in a different region of the file — 3535 against ~4341 — so a line-numbered patch would have rewritten the wrong row in one of the three languages. ## What was wrong `conn.gap.aluminium.scope` said the joints of aluminium members "can fall outside this list even though the metallic inventory does list them". Wiring the grade catalogue in `6d274e37` ended the second half: the inventory admits rows by `isSteel`, so an aluminium member is not listed there either — it is NAMED, in a notice, and a model whose only metal is aluminium reports `nonFerrousOnly`. The sentence was not dangerous — it still warned about aluminium and still said the bolt and electrode tables are steel's — but it described behaviour the app no longer has, in the one block whose entire worth is that it can be believed. And M1 caused it. ## What the new text does States that the joints fall outside the list, that the inventory does not list them EITHER, and what happens instead: it names them. Then keeps the half that was always right, that the tables are steel's and would be wrong for aluminium even if the joint appeared. The sibling key `conn.gap.aluminium.missing` is untouched: it says `materialFamilyOf` cannot tell aluminium from steel by magnitude "until the material declares its grade", which is still literally true. ## The guard, inverted rather than deleted `conn-aluminium-scope.test.ts` asserted the pre-patch text so the wait could not be forgotten. It now asserts the corrected values, that the false clause is gone in all three languages, and that H1's floor-state keys are either absent or all seven present — never the partial set a careless conflict resolution leaves. One assertion in block 2 compared the proposal against what was shipped; that one belonged to the pre-patch world and is now a check on the sentence itself. Locale parity is unchanged because no key was added: es 5572 / en 5571 / pt 4590, identical to `08917b9f`. `conn.*` is 77 in all three. The 1176-key Portuguese gap in the main dictionary predates this branch and is not M1's to close.
…t, and the contract ships on its own branch
## The contract, delivered in isolation
`contract/grade-family` at **`168320b0`**, cut from `08917b9f` — the base M1 and
H1 share — with two new files and zero edits:
web/src/lib/engine/steel/grade-family.ts 86 lines
web/src/lib/engine/steel/__tests__/grade-family-contract.test.ts 148 lines
`git cherry-pick 168320b` and nothing else comes along. M1's own commit for that
module, `6d274e37`, touches seven files — the inventory, the metallic store, three
steel dictionaries — so it was the wrong unit to hand over.
The test is deliberately not M1's. `grade-family.test.ts` asserts the contract AND
its consequences for the metallic inventory, which depend on M1's changes to
`steel-inventory.ts`. The one on the contract branch depends only on the module,
the catalogue and `material-family.ts`, all unchanged on the base — **12 tests,
green on `08917b9f` with nothing of M1 present**, which is the proof that the
contract is self-contained. Section 11 of the contract document has the commands.
## §3 and §4 of the checklist, automated
`m1-generators-joints.spec.ts` — 14 tests — covers what was manual-only: the three
generators previewing and following their parameters, a refusal tied to the button
by `aria-describedby` and recovering, the promised count landing for all three
kinds including the shed that merges nodes by coordinate, a properties-only
profile refused for compound arrangements with its reason on screen, a generated
model reporting no results rather than pretending, and the bracing switches being
additive. Then the joints panel: banner before any number, the four sub-sections
in order, the non-metallic count, the FvExcl warning beside the result and only
for 4.6 and 5.6, the five limitations with their four facets each, torsion marked
as NOT affecting the result unlike the other four, and the corrected aluminium
sentence as it reaches a user.
Three more checklist claims were wrong, and the tests found all three:
· the arrangement select of a profile row is neither the first select on the
panel (that is the truss kind) nor addressable by label alone (that matches
all three role rows: 15 options, 1 refused plus 7 and 7). The app was right
both times; my locator was not.
· `portico2d` is not in `fixture-index.ts`. Loading it yields an empty model and
the failure surfaces as a timeout rather than "no such example".
· the checklist asked for a mixed model without saying how to get one.
All three recorded in the checklist as corrections, alongside the two from the
previous turn, so a manual reviewer is not sent looking for something that is not
there. What stays strictly manual is now explicit: whether things fit, whether a
notice lands at the right moment, whether the prose reads like a person wrote it,
and the state sequence a user actually walks.
…the grade-family copies verified identical
Source untouched: one e2e spec, one document, one checklist update.
## The contract: identical, and the merge is a non-event
H1 has the same `grade-family.ts`. Verified rather than assumed, read-only, no
cherry-pick and no integration:
· byte-for-byte identical across all three refs — M1, `contract/grade-family`
and `origin/feat/pro-concrete-h1` — digest `f9ccb531308b14d2`, 86 lines, not
a whitespace apart;
· it arrived independently. `168320b0` is NOT an ancestor of H1; the file landed
inside their own `23ce3e34`, and their commit message says they took the
module as-is rather than reimplementing it because two functions answering one
question become two answers;
· `git merge-tree --write-tree` reports zero conflicting paths. Git resolves an
added-on-both-sides file without flagging it when the content matches, so the
physical consolidation is not pending work with risk — it is a non-event.
So `contract/grade-family` is no longer needed as a delivery vehicle. It stays
published because it documents the contract with a self-contained test; deleting
it is the integration's call, and the policy's condition for deleting a branch is
already met — the work provably exists elsewhere, with the same digest, on two
branches.
The two test files do not overlap. Mine covers the whole catalogue, non-
normalisation of input and the consequences for the metallic inventory; theirs
covers the pipeline admission and the timber defect, and asserts something mine
does not: that timber and masonry belong to neither pipeline. They complement.
One observation stays open and is not M1's: `ContextModelData.materials` still
does not declare `gradeId` in its type. H1's message confirms the field always
arrived because the live `modelStore.materials` is passed, so it works; the
interface still does not name it.
## §5, the warning timing, and the languages at runtime
`m1-states-and-languages.spec.ts` — 18 tests — closes the last automatable part
of the checklist. The states as a SEQUENCE, which is why they were manual: an
unsolved model reporting the demand as unavailable in words rather than by colour,
a solve that moves the state and never to a pass, an empty model saying it has no
elements rather than no steel, an all-concrete model showing the census that turns
"no steel" into "400 members and none of them are", and the banner that has no
close control anywhere inside it.
The language checks read RENDERED text on every metallic panel, not the
dictionaries: `t()` returns its own argument when a key is missing, so a key
printed into a panel is the failure mode and only a rendered page shows it. The
same pass audits for the four approval words outside a denial — the runtime half
of `steel-never-verified.test.ts`, which reads source.
The warning-timing audit found no defect and one thing worth writing down: the
FvExcl warning is conditioned on the GRADE, not on the threads checkbox its own
comment names. The code is the more conservative reading — it appears on selecting
4.6 or 5.6 before any result exists, so a user who never touches the checkbox is
still told that unticking it would change nothing. Pinned, along with its
disappearing for 10.9, because a warning that fires where it does not apply
teaches the reader to ignore it.
57 automated tests now cover the checklist. What stays manual is explicit: whether
things fit, when a notice lands, whether the prose reads like a person wrote it,
and the two §5 items that need the regulations panel and a hand-edited material.
…e not the debt they look like
Source untouched: two documents, one unit gate, one e2e spec. Nothing translated,
which is the point.
## Three corrections to the premise
The block asked for an audit of "the 12 languages that still fall back to
English". Measured:
· **eleven, not twelve.** The app ships fourteen dictionaries; three are
offered.
· **none of the eleven is reachable.** `OFFERED_LOCALES` is es/en/pt,
`detectBrowserLocale` is TYPED to return an offered locale, and
`getInitialLocale` refuses a stored locale that is no longer offered — with a
comment saying honouring it "would resurrect exactly the half-translated
state this exists to remove". The app narrowed to three on purpose. Forcing
`stabileo-lang=de` does not give German; it falls through to detection.
· **the eleven are not empty, which is worse.** Each already carries 22
`conn.*` labels written before the metallic namespace existed. So offering a
fourth locale is not "translate 314 keys": it is translate 292 and reconcile
22, and a German user today would see 22 German strings among 292 English
ones inside the joints panel.
So translating the namespace into the eleven changes nothing anyone can see. The
real question is which locale gets offered next, and that is a product decision
M1 does not take.
## What was worth doing instead
The 314 keys are classified by what they DO, because that is what decides
translation order: 197 labels, 64 warnings, 24 assumptions, 21 help strings, 8
state labels and descriptions. **96 of them change how a result is read** — a
warning explains why a number is absent, an assumption travels onto a report, and
a state is the difference between "nobody tried" and "something was computed with
no authority behind it". Those go first; the labels can wait.
The counts come from the same categoriser the test uses. A first draft of the
document said 193/66/30/4 from a looser regex that also matched keys with
`.assume` in the middle; the test is now the source and the document cannot
outlive it.
`m1-steel-terminology.md` is the artefact that survives whichever language comes
next: eleven terms whose translation is a decision, not a lookup, with
alternatives and the criterion for choosing. Five have little or no margin. The
worst is "experimental" — it must say there is a number with no authority behind
it, never "this is in beta" — and the French section modulus, where three terms
circulate and EN 1993's own French edition is the tiebreak. For ja, ko, zh, ru,
ar, hi, tr and id I propose nothing: I have no basis, and proposing would be
inventing.
## Gates
The unit gate asserts the namespace is complete in every OFFERED locale, so the
half-translated state comes back as a red test if `OFFERED_LOCALES` grows without
the namespace growing with it. It also catches the inverse — identical es/en
values, which are usually a paste. That found one legitimate exception:
`steel.status.EXPERIMENTAL` is "Experimental" in all three, a cognate rather than
a copy, and it is a named exemption rather than a loosened rule.
The e2e spec pins the commitment in a browser: a stored `de` does not survive the
boot, the joints panel reads as one language rather than leaking the 22 German
labels, no raw key renders after a fallback, and each offered locale holds the
props card on its own instead of falling through to English.
## Stopped before the shared file
Offering a fourth locale needs two edits to `lib/i18n/store.svelte.ts:36-41` and
`:73`, and adding a value to `OFFERED_LOCALES` commits the whole `design.*`
namespace — most of the work would be concrete's, not steel's. Reported in the
audit §7 with the contract, the impact and a proposed owner: product first, then
a common integration.
…e AA failure found Nothing implemented. `tokens.css` and all six consumers are untouched, and stay that way until H1 and M1 agree the contract. ## The three tokens do not exist `--st-danger-bg`, `--st-warn-bg` and `--st-provisional` are all undefined today. `OutcomeBadge.svelte:82` already says so in as many words — "`--st-provisional` is owed". What exists is the role TEXT colours and one tokenised tint, `--st-selected-bg`; every other state background is hand-written `rgba()`, and they do not agree: three values and two hues for warn, two alphas for danger, six literals for provisional. ## The defect measuring turned up `OutcomeBadge .badge-fail` and `DesignToolbar .banner-block` put `--st-accent` — the brand vermillion — as text over a red tint. Composited over `--st-surface` that is **3.86:1 and 3.93:1, both under the 4.5 AA floor for small text**. `--st-danger`, which exists for exactly this, is 5.05:1 on the same background. Those two rules reach for the brand colour where the status colour belongs. The test asserts it AS FAILING rather than skipping it, so the suite cannot be green while the screen is not, and so repairing it turns the file red instead of leaving a stale exemption behind. ## What is proposed `--st-danger-bg: rgba(192,57,43,0.14)` and `--st-warn-bg: rgba(184,134,11,0.14)` — the PLAIN hue, not the `-text` one, because a background is area and the palette already splits those two uses. 14 % rather than 16 %: at 16 % danger on `--st-surface-2` is 4.86, still AA with 0.36 to spare; at 14 % it is 4.96. Provisional gets THREE tokens, and that is measured rather than tidy: `#a066d3` as TEXT on its own tinted background is **3.55 — under AA**, while as a border or figure it is 4.30, which clears the 3.0 non-text floor. So `--st-provisional` (fill, the 3-D authority value), `--st-provisional-text` (#d8b4ff, 7.92 on the tint) and `--st-provisional-bg`. One token would invite the failing use. `--st-ok-bg` and `--st-info-bg` are deliberately excluded: `ok` never diverged, and neither `info` use is a risk banner. Widening the shared surface without closing a defect is not worth a coordination round. ## The constraint that makes it delicate `three/rebar-scene.ts` owns `0xa066d3` as a numeric hex for a Three.js material and cannot read a custom property. Three existing assertions lock the agreement by literal — `run-summary-reported.test.ts:122` and `viewer-design-system.test.ts:79,81` — so a naive tokenisation breaks two of them and, worse, deletes the guarantee that the viewport violet and the panel violet are the same colour. The proposal replaces "both files contain the literal" with "both resolve to the same value", in that order: token, then equivalence test, then consumers. Backwards there is a window where nothing checks it. `FloorFamilyStateCard` calls provisional amber in two places and should move — but only after the token and its tests exist, or it references an undefined property, which is the silent failure `design-tokens-resolve.test.ts` was written to catch. And its two uses need different tokens: a border takes `--st-provisional`, a small label takes `--st-provisional-text`. ## Owner and scope Five of the six consumers are concrete's, the AA failure is in two of them, and `FloorFamilyStateCard` is H1's. `SteelStatusBadge` needs no change at all — its warn tone is a deliberate diagonal hatch, distinguishable without hue. So: implementation H1, `tokens.css` a common integration, M1 contributes the contract, the numbers and the contrast test. One first-draft error worth recording: the test claimed `--st-amber` fails as text because the palette's sentence reads as if it covers all four plain hues. It does not — amber is 5.20 and passes, while red, green and blue fail. The number is the authority and the argument for splitting the violet never needed the analogy. Gates: typecheck 479/479 with no new errors, unit 7066 passing, check:gate clean, smoke 284 passing on E2E_PORT=4305, concrete digest unchanged.
…lure — this one M1's own H1 published its own proposal in `54ba5023`, written without seeing M1's. The shared contract is still NOT implemented: `tokens.css` is untouched on both branches, verified on both. ## They converge, at the value Both propose `--st-danger-bg: rgba(192,57,43,0.14)`, `--st-provisional: #a066d3`, `--st-provisional-bg: rgba(160,102,211,0.16)`, the three-token provisional split for the same measured reason (#a066d3 as text on its own background is 3.55 and fails AA), Three.js keeping the numeric authority with the panel dots left literal and a mirror test comparing values, and the same migration order with `OutcomeBadge` last. Two branches measuring separately and landing on the same alpha and the same split is the signal the contract is right. ## Four differences, and M1 concedes one **`--st-provisional-text`: H1's `#c08ae6` beats M1's `#d8b4ff`.** H1's argument is that the existing `-text` variants live in a band — 4.97 to 7.52 on `--st-surface` — and the new one should enter it. Measured, `#c08ae6` is 6.46 and sits inside; `#d8b4ff` is 9.58 and sits above all four. M1's argument was "it is what OutcomeBadge already uses, so nothing changes visually", and that was weaker than it looked: `ProvisionalBanner` already uses two violet text values, so there was no canonical one to preserve. Conceded. **`--st-warn-bg` alpha: M1 asks for 0.14 on H1's own stated rule.** Both clear AA everywhere and the gap is 0.17 of ratio, so this is not an accessibility call. But H1's §2 says the alpha "copies the only precedent, `--st-vermillion-dim` at 0.14", then uses 0.14 for danger and 0.16 for warn. Either the token follows the precedent or the sentence goes. **`FloorFamilyStateCard` needs BOTH tokens.** H1's §3 table says `--st-provisional`, their §5 step 4 says `--st-provisional-text`. The card uses the hue in two roles: a `border-left-color` (3.0 floor) and a label `color` (4.5 floor), and `#a066d3` is 4.30 — fine as a border, not as text. So border → `--st-provisional`, label → `--st-provisional-text`. **The AA failure is absent from H1's proposal**, and that is the one with consequence. Their tables measure the NEW backgrounds and all pass; what they do not mention is that rules which exist today put `--st-accent` on a tinted background and fail. Tokenising the background does not fix it — 3.86 becomes 3.84 — the text colour is what fixes it, and three of those files are theirs. ## The fourth failure, and it is mine The test M1 wrote to forbid exactly this on metallic surfaces failed against PR21's own joints panel: `.conn-ratio-badge.st-fail` puts `--st-accent` on a 20 % vermillion tint, 3.55 on `--st-surface` and 3.41 on `--st-surface-2`. Its two siblings pair each tint with its own role colour; only the failure case reached for the brand. Not fixed, and the reason is the contract: at 20 % even `--st-danger` reaches only 4.46 on `--st-surface-2`, so that badge needs a lower alpha — `--st-danger-bg` at 14 % gives 4.96 — or `--st-text`. Choosing now would pre-empt what H1 settles. It is named, measured, and listed by name in the test so a NEW instance fails rather than joining a tolerated set. **Four, not three.** And one correction each way: H1's inventory says `SteelStatusBadge` "inherits without being edited" because it "references OutcomeBadge" — it does not, it imports only `i18n` and `steel-status` and owns its four tone classes. Their conclusion is right for a different reason: it has no tinted danger or warn background at all, because its warn tone is a diagonal hatch. And H1's inventory corrected M1's: with `VerificationDetail` counted, the warn family is six tinted backgrounds across two hues and five alphas, not the three M1 reported. One overreach removed: a first draft banned `--st-accent` as text anywhere metallic. That forbids the correct use — it is the primary-action and selected token, and the pickers use it to mark a chip on and a row selected. The rule is about a tinted status background, not about the token. Gates: typecheck 479/479 no new, unit 7077 passing, check:gate clean, the three metallic e2e specs 57/57 on E2E_PORT=4306. No shared surface touched.
… label on their own tint
H1's contract is verified and NOT duplicated. `dfa20d8b` ships the five tokens
with every value matching the reconciled proposal, and on the two contested
points it took M1's: `--st-warn-bg` at 0.14, and `--st-provisional-text` at
`#d8b4ff` — the value M1 had conceded away and H1 reverted with the better
argument, that 9.58 beats 6.46 and it is what `OutcomeBadge` already ships, so
adopting the token moves no pixel there. No consumer was touched by that commit,
deliberately, so M1 could check the contract before anything depended on it.
`tokens.css` is not edited here and no shared consumer is. This commit is one
metallic file.
## The fix, and the fifth failure
`.conn-ratio-badge` reports a bolt group's or a weld's governing ratio as a label
on a tinted fill. Composited over the two grounds the panel sits on:
.st-ok --st-ok 3.75 / 3.64 under AA
.st-warn --st-warn 5.22 / 5.03 fine
.st-fail --st-accent 3.55 / 3.41 under AA, and the brand colour at that
Two of the three were illegible. The brief named `.st-fail`; `.st-ok` was found by
the very assertion the brief asked for — a 4.5 floor for text on a tinted
background — and hiding it would have meant weakening the test to fit the
instruction. Amber passing and the other two failing matches the palette-wide
finding that amber is the bright exception among the plain hues.
`--st-danger` does not rescue `.st-fail` either: 4.46 over `--st-surface-2`. At the
contract's 0.14 it would be 4.96, but `--st-danger-bg` is not on this branch —
referencing it would be an undefined custom property, which draws nothing at all
and is exactly what `design-tokens-resolve.test.ts` was written to catch.
So the role moves to the border and the label goes neutral: `--st-text` is 10.3 to
13.1 on these fills, and a border needs only the 3:1 that WCAG 2.1 §1.4.11 asks of
a non-text boundary, which all three clear. It is the pattern H1's own migration
settled on for `DesignToolbar .banner-block`.
All three are changed, not just the two that failed. They are a set read one after
another, and leaving `.st-warn` as the only badge whose label carries the hue would
make the difference between states look like it meant something it does not. The
base class now reserves a transparent border so a change of status does not shift
the row by 2 px — the same thing `OutcomeBadge` does, for the same reason.
The tints stay literal, and a test fails the day `--st-danger-bg` appears here, so
migrating them is not forgotten.
## Tests
`steel-surface-colour-rules.test.ts` grew the five distinctions the brief asked
for — failing on a tint, warning on a tint, correct on a tint, legitimate
`--st-accent` selection, and a 4.5 floor for every label — plus the size
assertion. The known-instance list for accent-on-red-tint is now empty and stays
as a guard rather than being deleted: the shape of the defect is what needs
watching.
`--st-accent` is untouched wherever it means selection: the family chips, the
selected row and the active pin in both pickers. A first draft of this rule banned
it outright on metallic surfaces, which forbade the correct use of the
primary-action token.
`SteelStatusBadge` is unchanged. Its diagonal hatch is a deliberate signal —
readable with hue ignored — and it is not a status background.
Gates: typecheck 479/479 with no new errors, unit 7083 passing, check:gate clean,
42 metallic e2e on E2E_PORT=4307, smoke 284 on 4308. Concrete digest untouched.
…d the selector/model boundary pinned The brief asked for a feasibility study before implementing. It changed what there was to implement: of the six goals, four are substantially done, and the main risk in this block was re-building them. ## What the audit found, measured not assumed **Regulation** — selectable (CIRSOC 301:2018 and EN 1993-1-1 in `roles.ts`), experimental-marked, and **persistent**: this is where my previous report was wrong. The bindings are not in `file.ts`; they live on `modelStore.model.regulations` and travel through `snapshot()`/`restore()`, which is what all four persistence paths share. `regulations-persistence.test.ts` already pins it. **Materials** — done. `lib/grades/catalogue.ts` sources Basic's own `structural-grades` and exposes designation, standard, family, origin, thickness bands with the code that tabulates them, and the authority behind each number. **Profiles** — done, and "thin-walled" splits in two: parametric thin shapes EXIST (`THIN_SHAPES`, offered by `ProSectionsTab`), while a cold-formed C/Z CATALOGUE does not and is already declared absent in `section-catalog.ts:121`. Adding it is table acquisition and validation, not wiring. M2. **Built-up sections** — they already render correctly in "Modelo con secciones": `create-element-mesh.ts:98` calls `createSectionShapes` when the mode is `sections`, and that reads `composition`. Confirmed in the code rather than assumed, and `built-up-extrusion.test.ts` holds it. **Workflow** — does not exist, and reusing `StageSection` for it needs H1. M2. ## Implemented **The code edition and the support maturity on `SteelPanel`.** A project could declare CIRSOC 301 and the panel said so without saying which text — and the concrete role carries two editions, so an edition is a real choice a reader could not see. Both fields are already on the binding, copied at bind time precisely so a stored project stays readable. The catalogue's own `noteKey` — the sentence saying the official text ships with the app and what is missing is the adapter — is NOT on the binding and there is no lookup by `adapterId`. Adding one means editing the shared regulation catalogue, so it is reported instead. **The selector/model boundary, pinned.** "Changing a section in the selector is reflected in the generated model" has two answers and only one is right: before Generate it is reflected, after Generate it must NOT be, because a generated model is geometry in the store and mutating it from a form the user has moved on from would leave undo nothing coherent to step back to. Nine tests, including mutating the very spec object that was passed in — the strongest form of the check — and the property that keeps "no parallel catalogues" true across all three generators. ## Stopped, with the contract written The most valuable task — recording how a parametrically built section was built, so the viewer draws its real outline instead of falling to `createSectionShape`'s I-beam default — needs a new field on `interface Section`, which lives in `model.svelte.ts`. My own study first claimed an alternative avoided that; the alternative is false, and verifying it rather than assuming it is what caught it: `Section.composition` names a catalogue `profileName`, which a parametric section does not have, and inventing one would be the very defect that field closed. `ModelProvenance` is model-level, not per-section. So the field is proposed with its doc comment, its impact, and a common integration as owner. Nothing was edited, and the hole is not filled with a fake `composition`. `SectionShapeBuilder.svelte` turns out to be orphaned — i18n keys in ten locales, imported by nothing — while the capability stays reachable through `SectionChanger` and `ProSectionsTab`. Recorded; deleting or mounting it is a product call with no functional urgency. Gates: typecheck 479/479 no new, unit 7092 passing, check:gate clean, 28 metallic e2e on E2E_PORT=4309. No forbidden file touched.
Adds one optional field to `interface Section`:
built?: { shapeType: string; params: Record<string, number> }
## What was missing
A section reaches the model three ways, and only two could say where they
came from: a catalogue pick carries `profileFamily`, an assembly carries
`composition`, and a section built from a template carried neither. Its
DERIVED numbers were stored — `a`, `iy`, `iz`, `j`, the shape tag, the
thicknesses — and its INPUTS were discarded. So a built section could not be
re-edited (nothing recorded what was typed), and it was the one kind with no
answer to "where did this come from".
And a parameter the apply path forgot was invisible. `computeSectionProperties`
takes `tl` — the lip thickness of a cold-formed channel — as its own input and
returns it; `handleShapeConfirm()` enumerated the fields by hand and that one
was not on the list. `createSectionShape`'s `case 'C'` substitutes the FLANGE
thickness when `tl` is absent, so the properties were computed from one lip and
the outline drawn from another. They agreed only while a user left `tl = tf`,
which is the template default (0.009 m both) and the reason it went unnoticed.
This commit passes `tl` as well.
## Why it is additive
`snapshot()` destructures the whole section (`const { canonical: _drop, ...rest }`)
and `restore()` copies it, so neither had to change: `.ded`, undo/redo and tab
capture carry the field already. The field is optional, so every stored model
predating it stays valid.
Declarative, like `composition`: nothing in the properties path reads it. The
stored `a`/`iy`/`iz`/`j` remain authoritative and the canonical resolver is not
handed a second opinion about the geometry.
No fabricated `composition` — `profileName` is documented as an exact catalogue
name and a built section has no catalogue part to name. Not `ModelProvenance`
either: that records where a whole MODEL came from, and a project can mix a
built section with catalogue picks.
## Stated limitation
A share link does not carry it. `compressV2` encodes a section as the positional
tuple `[id, name, a, iz, {s,b,h,w,f,t,iy,j,rot}]`, which has never carried `tl`,
`profileFamily` or `composition` — an assembly shared by URL already comes back
without its make-up, today. Widening that format is a separate versioned
decision (`SHARE_VERSION`) on a file shared with the concrete work. The test
pins the current loss instead of hiding it.
## Tests
`built-section-contract.test.ts`, 17 cases: the record (all template parameters,
copied not referenced), sufficiency (feeding `built` back through
`computeSectionProperties` reproduces the stored properties, for every template),
persistence (snapshot/restore, undo, redo; the share-link loss asserted
explicitly), backward compatibility (a snapshot with the field deleted restores
clean; catalogue picks and assemblies get no fabricated record), and
visualisation (the entered lip is drawn, every template draws a real outline,
the outline survives a reload).
Also corrects this repo's own study. `m1-m2-scope-split.md` claimed a parametric
section falls to `createSectionShape`'s I-beam `default:`. It does not — every
shape tag `computeSectionProperties` emits has a case, and the test now fixes
that. The claim came from carrying PR21's generator defect over to a path where
it does not occur.
Full unit pass: 7109 passed. Typecheck: no new errors against baseline.
…eported to H1
Three documents, no code.
## `m1-section-shape-builder.md` — documented, not deleted, not mounted
`components/SectionShapeBuilder.svelte`, 500 lines, **zero importers** — static or
dynamic. The only mention outside the dictionaries is a comment in
`utils/section-drawing.ts`.
It did not fall out of a refactor by accident: `d65cd01b` EDITED it 53/51 in a
commit whose message says "Both dialogs are now free of hard-coded colour",
migrating it from the Steel/Concrete taxonomy to Thin/Solid. It survived a
refactor that treated it as a live screen. `git log -S` over the `.svelte` files
shows no commit that ever added or removed an import of it.
What it duplicates: the templates and the maths (same `SECTION_SHAPES`,
`computeSectionProperties`), and the outline preview (`crossSectionPath`, which
both mounted dialogs also use). The duplication is of interface, not engineering
— there is no parallel catalogue.
What exists ONLY there: the m ↔ cm unit toggle (`grep -c displayUnit` → 0 in both
`ProSectionsTab` and `SectionChanger`), and the text explaining why the
thin/solid distinction matters (`shapeBuilder.thinHelp`/`solidHelp`, used
nowhere else) — which is the very argument `d65cd01b` gave for the taxonomy it
introduced, sitting on the screen that cannot be opened.
What it costs: eleven keys in fourteen locales, and they are REQUIRED —
`basic-mode-coverage.test.ts` walks `components/` and demands es/en/pt answer
every literal `t('...')` it finds, so the keys are maintained because the file
exists, not because anyone sees them. Two of them (`shapeBuilder.steel`,
`shapeBuilder.concrete`) are used by nobody at all, including the orphan:
leftovers of the abandoned taxonomy, translated fourteen times.
So the product decision is not delete-or-keep. It is: does the section builder
carry a unit toggle and an explanation? Recommendation: port those two to the
mounted dialogs, then delete. The cold-formed profiles M2 would load are
published in mm, so it is worth deciding before M2, not after.
## `m2-scope.md` — six phases, each marked DATA / AUTHORITY / SHARED
Three findings that change M2's size, verified rather than assumed:
* The seat exists. `codes/roles.ts` already declares `role: 'steel'` with
`cirsoc301-2018` (experimental, UNSUPPORTED, `textAvailableNotImplemented`),
and `codes/capability.ts` already enumerates the ten steel capabilities.
* **The official CIRSOC 301-2018 text ships with the app** —
`docs/codes/CIRSOC/markdown/cirsoc-301-2018/`, chapters A–N and eight
appendices. Phases 2 and 4 are NOT data-blocked; they are blocked on
implementation and on a human signature.
* **CIRSOC 303 does not ship.** The cold-formed code that governs phase 3 is
absent, which is why that phase is the only one blocked by DATA and
AUTHORITY at once — and why loading C/Z tables and verifying them to 301
would be the wrong method under a correct label.
Phase 3 also carries a precise correction: the catalogue's `C`/`MC` families are
American HOT-ROLLED channels (ASTM A6), not lipped cold-formed C. There is no
cold-formed series in the profile catalogue, while `grades/catalogue.ts` already
has a `cold-formed` family — a cold-formed steel can be declared and no
cold-formed profile exists to give it.
Phase 5 (authority) blocks nothing: phases 1–4 can be done in full with it
closed and the result stays honest. If a phase needs it open to make sense, that
phase is mis-scoped.
## `m1-h1-coordination.md` — point 5, the `built` field
File, type, commit, compatibility and the 17 tests, reported for `ae3a6186`.
Flags that `ProSectionsTab` also serves the concrete templates (it starts on
`concrete-rect`), so H1 knows the file moved: two extra fields written, no
concrete section changed.
Also reports a pre-existing loss H1 should know about and that this branch does
NOT fix: a share link carries no `tl`, no `profileFamily` and no `composition`,
so an assembly shared by URL already comes back without its make-up. Widening
`SHARE_VERSION` is a versioned decision on a shared file.
Opens M2's first block: cold-formed open sections as GEOMETRY. No strength, no
classification, no plastic modulus — see the state wiring in a later commit for
why that is a normative fact and not a gap.
## Parametric, not tabulated — and why that is the honest shape
Every other family in this catalogue is a table: `iram-c.ts`, `iram-angles.ts`,
`iram-tubes.ts` and the DIN series each name a dimensional standard, list its
rows, and treat the published area and inertias as the authority, with the
outline built to reproduce them. Cold-formed C and Z do not work that way:
* a cold-formed section is bent from sheet, so its whole geometry is four
numbers and ONE thickness runs through web, flange and lip alike — there is
no independent web thickness to look up;
* which COMBINATIONS are commercially available varies by mill and market.
That is a commercial fact, not a normative one, and this repository has no
sourced copy of it. **No dimension rows are shipped and none are invented.**
* given the four numbers, the properties follow exactly.
So the identifier carries the data: `C 100x50x15x2.0` IS its specification, and
a lookup parses instead of consulting a table. A saved project therefore opens
with no catalogue behind it.
`ColdFormedSpec` has one thickness, deliberately: the generic `C-custom`
template leaves `tw`, `tf` and `tl` free, which lets a user describe a section
no mill can make. Here that is not expressible.
## The product of inertia, which the app cannot store
A C is symmetric about its horizontal axis, so its geometric axes are principal.
A Z is only POINT-symmetric: its product of inertia is nonzero, its principal
axes are rotated, and **there is no `ixy` field anywhere in this app**. So the
geometry reports `ixyMm4`, the principal pair and the rotation — not to work
around the limitation but to make it measurable. For the reference Z the product
is over half the weak-axis moment and the rotation exceeds 10°, both asserted.
`rotation` on `Section` is not a place to put it: that field is the section's
installed roll, consumed by `scene-sync.ts` offsets and `despiece-3d.ts`.
## Sharp corners, declared and quantified
A real bend has an inside radius; the radius RULE belongs to the forming
standard, and guessing `r = 2t` is exactly what `steel-profiles.ts` forbids for
root radii ("must never be guessed or back-solved from A or I"). Corners are
modelled square, and the cost is computed rather than asserted: a square corner
counts `t²` where the sharpest possible bend counts `(π/4)t²`, so four corners
of a `C 100x50x15x2.0` are 3.43 mm² of 452 — 0.76 %, always in the same
direction, so the model is never unconservative about area.
## Verified from outside itself
`cold-formed-geometry.test.ts`, 19 cases over a 200-section grid. A module that
derives its own numbers can only be trusted if the derivation is checked
externally:
* **the C against code this module did not write** — `computeSectionProperties`'
`C-custom` case, fed one thickness in all three slots, must reproduce area,
both inertias and `j` exactly;
* **the Z against the C** — turning the bottom flange around moves material
horizontally, not vertically, so the two share `iy`, area and `j`. That
identity caught a real bug: `partsZ` was measuring the lip from the flange's
outer face while `partsC` measures from its mid-thickness, a `t/2`
disagreement the module had with itself;
* **the principal axes by rotating the tensor** — not by re-deriving the
formula. This caught a second real defect: `atan2` spans (−90, 90], not the
(−45, 45] the field documented, so a shallow wide Z reported 46° where −44°
is the same pair of axes. Now normalised to the nearer principal axis.
Both invariants are asserted too (trace and determinant), because they hold for
any correct tensor and no incorrect one, independent of sign convention.
The header's own quoted area was wrong on first writing (366 mm², actually 452);
the test computes both numbers, which is how it got corrected.
The second edit to `model.svelte.ts` on this branch, taken because the incompatibility is demonstrable rather than anticipated: `createSectionShape` dispatches on `Section.shape`, which is an inline literal union, so a zed without a literal there has NO outline — it renders as nothing, which in a viewer reads as a missing member rather than an error. There is no way to draw one without the literal, and hacking the dispatch to key on something else would be worse than the additive change. Two one-word additions: `Section.shape` in `model.svelte.ts:107` and `SectionShape` in `steel-profiles.ts`. Both are unions of string literals, so every existing value keeps its meaning and no stored model changes. ## The outline `createZShape` mirrors `createCShape` exactly — same parameters, same meanings (`t` is the lip LENGTH, `tl` the lip thickness), web at `x ∈ [0, tw]` with the top flange running to `+b` and the bottom to `tw − b`. So a Z section stores precisely what a C stores and nothing about it is new to the viewer. Guarded like the channel is, plus one case specific to the zed: its flanges are traversed in BOTH directions, so a flange narrower than two thicknesses crosses the path where a channel's would not. Either degeneracy falls back to the same section without lips rather than self-intersecting and feeding earcut garbage. ## A discrepancy in the channel that writing the zed exposed Deciding where a lip starts turned up an inconsistency that was already there: `computeSectionProperties` measures the lip from the flange's MID-thickness (`(h − tf)/2 − c/2`) while `createCShape` draws it from the OUTER face. The two models of the same channel therefore differ by `2t²` in area — 8 mm² of 452 on a `C 100x50x15x2.0`, about 1.8 %. Pre-existing and not introduced here. The zed follows each convention wherever the channel does, so the discrepancy stays uniform and fixing it later is one decision instead of two. Asserted on the CHANNEL as well as the zed, because an inconsistency nobody wrote down is one that gets fixed in one place only. ## Tests `cold-formed-shapes.test.ts`, 13 cases, none of which needs a rendered image: point symmetry checked over the vertex list (`(x,y) → (tw−x, −y)` must map the set to itself — a channel would not), area by shoelace against the closed-form material, the `2t²` discrepancy on both shapes, every degenerate fallback still a simple closed polygon with positive area, and the full path a viewer uses: designation → entry → section fields → `createSectionShape`.
…s why
Completes the code half of M2's C/Z block: the selector, the boundary with the
store, and the honest answer when someone asks whether these sections are
verified.
## A lookup with no table behind it
`createColdFormedSource(series)` takes its series as a parameter — the same seam
`GradeFamilyLookup` uses. Three consequences, and the third is the reason:
1. the shipped source is built on `NO_SOURCED_SERIES`, so there is no row list
in the file to drift;
2. a project library or a mill catalogue plugs in without this module learning
about either;
3. **the filters are exercised for real.** Query logic tested only against an
empty series is untested logic that reports success, so the resolution test
injects a series and drives every filter — shape, depth bounds, thickness
bounds, text, combinations — through it. The day a sourced list lands, the
picker already works and this file does not change.
`byId()` resolves ANY valid designation with no series consulted, and resolves it
IDENTICALLY whether or not a series contains it — asserted, because a project
must not analyse differently depending on which library happens to be open.
`list()` returns the sourced series, currently empty, and `seriesStatus()` says
`noSourcedSeries` so a picker can state something true instead of showing a blank.
## `CFC`/`CFZ`, because `C` is taken
The tabulated catalogue already has a `C` family: the American HOT-ROLLED channel
of `iram-c.ts` (IRAM-IAS U 500-509-4), a different product under a different code.
A shared id would silently merge the two in every family filter and every saved
model. Asserted from both sides — no hot-rolled channel id parses as a
designation, and neither `CFC` nor `CFZ` appears in the tabulated source's
families.
## Not `ProfileEntry`, and the vocabulary gap that is why
`GeometryFidelity`'s three values (`exact`, `nominalDimensions`, `propertiesOnly`)
all presuppose a published table to be faithful TO. There is no value meaning
"the outline is exact and the properties are derived from it", which is what a
cold-formed section is — so filing one under `exact` ("verified against published
data") would be a claim about data that does not exist. `ColdFormedEntry` is kept
structurally close so a future unification is a rename, not a redesign; what that
needs is in the limits handoff rather than guessed here.
## The model boundary writes nothing false
`coldFormedSectionFields()` converts to SI and follows the `'C'` field convention
exactly. It writes no `built` — that names a `SECTION_SHAPES` template and there
is no cold-formed one, so `shapeType: 'C-custom'` would be the same species of
lie as a fabricated `composition`; none is needed either, since the designation
IS the parameter record. No `composition`. No `ixy`, because there is nowhere to
put it.
## The state, and a deliberate deviation from the brief
The brief asked for `DEMAND_UNAVAILABLE` on any verification that does not yet
exist. `steel-status.ts` documents that state as something narrower: "the member
is metallic and the forces are not there — no solve, no combinations. Distinct
from the two above because the remedy is the user's and it is obvious."
A cold-formed member has geometry and, after a solve, forces. What it lacks is an
AUTHORITY — and that is not an implementation gap but a normative fact, quoted
from the text this app ships. CIRSOC 301-2018, chapter A:
«Para el proyecto de elementos estructurales resistentes de: (a) chapa de acero
doblada o conformada en frío de sección abierta y sus uniones se aplicarán las
especificaciones del Reglamento CIRSOC 303-2009 …»
CIRSOC 303-2009 is not in `docs/codes/`. Solving changes nothing, so
`DEMAND_UNAVAILABLE` would put a true-sounding label on the wrong cause and send
a user to re-run an analysis that was never the obstacle — the defect this whole
surface exists to prevent. The state used is `NOT_DESIGNED` with a new reason,
`steel.reason.coldFormedOutOfScope`, naming the exclusion.
Placed after the authority check so `stateFor`'s stated ordering holds — most
actionable reason first. Today the branch is unreachable (nothing can bind a
metallic authority); the test binds one to prove it says the right thing on the
day something can. Recognising a cold-formed section costs a parse of the name it
already carries, so the inventory learns nothing about geometry.
es/en/pt at parity, 165 keys each.
7170 unit tests pass (+61). Typecheck: no new errors against baseline 479.
…on reports Four documents, no code. ## `m2-cold-formed-limits.md` — the sixth part of the C/Z block Five limits, each with the evidence and what would lift it. The two that matter: **No verification is a NORMATIVE fact, not a missing adapter.** CIRSOC 301-2018 chapter A excludes cold-formed open sections by name and defers to CIRSOC 303-2009 — and 303 does not ship, while 301 does. Quoted rather than summarised. Also records the deliberate deviation from the brief: `DEMAND_UNAVAILABLE` is documented as "the forces are not there… the remedy is the user's and it is obvious", which is the wrong cause here, so the state used is `NOT_DESIGNED`. **A zed's product of inertia has nowhere to live.** No `ixy` exists anywhere in the app, and `rotation` is the section's installed roll (consumed by `scene-sync.ts` and `despiece-3d.ts`), not a place to annotate principal axes. Stated without exaggerating in either direction: analysing a Z about its geometric axes is wrong UNLESS the member is restrained out of plane — which is the usual case for a sheeted purlin — but the provision defining that restraint lives in the cold-formed code that is absent. So the honest answer is not "wrong" and not "fine", it is "the assumption that would make it valid cannot be cited". Lifting it needs the model and the solver, which are out of scope here. Plus: the empty series and why an unsourced list is worse than none; square corners quantified (0.76 % on the reference section, under 3 % across the grid, always in the same direction); the pre-existing `2t²` disagreement between the drawn channel and the computed one; and the `GeometryFidelity` vocabulary gap that is why `ColdFormedEntry` is not a `ProfileEntry`. ## `share-codec-fields.md` — prepared, NOT implemented `compressV2` writes eleven of a Section's fields and drops four: `composition`, `profileFamily`, `tl` and `built`. The impact is wider than the Share button. `FeedbackWidget.svelte:62` attaches an auto-generated share link to **every feedback report** — so a user reporting a problem with a built-up assembly sends a link that rebuilds the model without the field the report is about. `edu/exercise-source.ts` distributes exercises the same way. The `.ded`, undo and tab capture are unaffected: the hole is the URL codec only, and what degrades is provenance and drawing, not the analysis. Migration proposed as one `SHARE_VERSION = 5`, with the design constraint that falls out of the format: the tuple is POSITIONAL, so new fields go as keys inside the optional object (an old reader ignores unknown keys) and never as a new array position. Ordered by bytes-per-benefit, `tl` first and `built` last since it is the only expensive one. Names the missing test that would have caught this: a completeness check over `interface Section`'s keys, failing when a field is neither encoded nor explicitly declared excluded — which is how four accumulated. Owner proposed: common integration, not either branch. The most visible loss (`composition`) belongs to both, and all three affected features are transverse. ## Coordination `m1-h1-coordination.md` gains point 6: the `'Z'` literal, why the incompatibility was demonstrable (a shape with no literal reaches no `case`, returns null, and renders as a missing member), and the one thing H1 should watch for — an exhaustive `switch` over `SectionShape` without a `default` would now flag. None exists today; the typecheck reports no new errors against baseline 479. `m1-section-shape-builder.md` gains the key-by-key inventory the brief asked for: of eleven `shapeBuilder.*` keys, three are shared with the mounted dialogs and survive any decision, six are exclusive and in play, and two — `shapeBuilder.steel`, `shapeBuilder.concrete` — are used by nobody at all, including the orphan. Across fourteen dictionaries that is twenty-eight entries nothing reads. Those two can go regardless of the product decision.
The C/Z block was a library with no surface. This gives it one, and makes the
five facts about its scope something a user actually reads rather than something
a handoff asserts.
## A selector with nothing to select from
The tabulated series ships empty and stays that way until a mill catalogue or a
dimensional standard can be cited. Normally that leaves a picker with nothing —
except a cold-formed designation IS its specification, so a section can be built
from the identifier alone. The two halves of a picker therefore come apart:
"choose from a list" is unavailable and says so; "specify a section" works
completely.
`ColdFormedPanel.svelte` is mounted inside `SteelPanel.svelte`, not as a new PRO
tab: `ProPanel.svelte` is the shared tab host that carries the concrete tabs too,
and a cold-formed section is steel, so the steel screen is where a user looks.
Four dimensions are the state and the designation is derived from them, so a
half-typed number never round-trips through a parser. Typing a designation writes
back into the four. An invalid spec names WHICH dimension is impossible rather
than printing "invalid".
## The five facts, as data
`cold-formed-scope.ts` holds them in reading order with a `kind` on each:
capability first (parametric geometry works), then the two absences (no tabulated
catalogue, CIRSOC 303 not incorporated), the exclusion (301 excludes by name),
then the conclusion (no normative verification).
Written as prose those five collapse into "cold-formed is not supported", which
is FALSE — the reader loses the capability. As data, each keeps its own kind, key
and place in the argument, the list is frozen so no consumer can reorder or
filter it, and a test can assert all five are present. `clause` is set only on
the two that are normative claims: "no sourced series" is a fact about this
repository, and dressing it as a clause would borrow authority it lacks.
## The zed warning is computed, not described
A zed's principal axes are rotated and this app has nowhere to store a product of
inertia, so the numbers shown are about axes it will not bend about unless the
member is restrained — and the provision defining that restraint is in the code
that is absent. The panel shows the ANGLE, because "rotated" and "rotated 23.4°"
are different warnings. Shown only for a zed: beside a channel, whose geometric
axes are principal, it would be a warning about nothing.
The square-corner cost is likewise computed per section, since it scales as t²/A
and one quoted figure would be right for one section and wrong for the rest.
## The zed had no 2D outline — it was drawn as a RECTANGLE
`crossSectionPath` had no `case 'Z'`, so every 2D surface that draws a section
(the stress panel's `CrossSectionDrawing`, the pickers) fell to `default:` and
drew a plain rectangle: a WRONG outline, not a missing one.
Fixed by giving the zed ONE definition and two renderers. `zedOutline` in
`profiles/cold-formed.ts` returns the vertex loop; `createZShape` turns it into a
`THREE.Shape` and the new 2D case into an SVG path. This app implements the
channel separately in three modules, and the first attempt at hand-transcribing
the zed into the second one produced a duplicated vertex and a reversed edge — a
self-crossing path. That is why it is shared rather than copied, and the 3D tests
passing unchanged after the refactor is the evidence the loop is identical.
Nothing in Basic mode can reach the new case: no `SECTION_SHAPES` template emits
shape `'Z'`, so only the cold-formed catalogue produces one.
## Tests
`cold-formed-scope.test.ts` (9) — all five present, in order, exactly one
capability, frozen, clauses only where a regulation is cited, every key resolving
in es/en/pt with the `{angle}` placeholder intact.
`zed-2d-outline.test.ts` (8) — the path parsed back into vertices, shoelaced
against the shared outline, point-symmetric about the centre, explicitly NOT the
rectangle the default branch produced, and inside the ±80 viewBox across a grid
(a zed spans 2b − tw where a channel spans b, while `sc = 80/max(h,b)` is sized
on b alone — it cannot overflow, and that is asserted rather than argued).
`m2-cold-formed-selector.spec.ts` (15 E2E) — the selector reached and used with
no series loaded, all five facts visible with the capability NOT styled as a
refusal, no verification vocabulary in any state and in any of the words the
constraint names, the zed warning present with its angle and absent on a channel,
and a specified section reaching the sections table under its designation.
Playwright ran on this worktree's derived port in the 5200–6199 band, never 4173.
7187 unit tests pass (+17). Typecheck: no new errors against baseline 479.
es/en/pt at parity, 188 keys each.
Two decisions prepared, neither implemented. Plus cross-references so the limits document points at both. ## `m2-ixy-integration-handoff.md` Leads with the finding that reorders the problem: **this is not a zed requirement.** An angle is not symmetric about its geometric axes either, and the catalogue carries **37 of them** — 27 in `iram-angles.ts` plus 10 European — all storing geometric-axis inertias with no `ixy` field anywhere in `src/lib`. Quantified, and validated against the catalogue's own published values before the number was used: a two-rectangle decomposition reproduces published area within 1–3 % and Iy within 1.5–5 %, so the resulting ratio is trustworthy — **the true minimum principal inertia of an equal-leg angle is ~40 % of what the app stores as its weak axis.** The stored value is ~2.4× too high, on the UNSAFE side, which is precisely why an angle strut is checked about its `v-v` axis in practice. Scope of that claim stated explicitly: what is verified is that the angles are selectable, that geometric inertias are stored, that no `ixy` exists, that the biaxial stress formula is the UNCOUPLED Navier (valid only about principal axes), and that no warning about principal axes exists anywhere. What is NOT measured is the end-to-end error in a specific model. Then the five things asked for: where the field would go (four layers, model first — the easy part and not where the problem is); which consumers need it (both solvers, both stress modules, buckling, modal, diagrams, shear flow — all but the last on the solver side, which is out of scope by explicit constraint); which assumptions are missing (whether the app analyses unsymmetric sections at all; when a sheeted zed's out-of-plane restraint holds, whose provision is in the absent CIRSOC 303; which axis governs an angle, which is normative); why it must NOT be derived silently (it is twenty lines of arithmetic, and that is the trap — half the catalogue has no outline to derive from, deriving would change saved results unasked, a present `ixy` implies the analysis uses it, and a factor of 2.4 is not an incremental improvement); and the split between M3 and common integration. The recommended first step is the cheapest and is neither: **warn that an unsymmetric section is being analysed about non-principal axes.** It touches no solver, covers the 37 angles and the zed at once, and is the only item that improves honesty without changing a number. ## `m2-lip-convention-proposal.md` Three implementations measure the lip, two ways: `computeSectionProperties` from the flange's mid-thickness, `createCShape` and `crossSectionPath` from its outer face. Same `c`, `2t²` of area apart. **The existing code recommends outer face, 2 to 1** — and two further arguments beyond the count: a cold-formed designation names the lip as an OUTER dimension, so under the mid-line convention a user typing 15 gets a lip `15 + t/2` deep; and the only real lipped product the app now catalogues is the cold-formed C/Z, whose own convention should govern. Both alternatives costed: A (mid-line) changes both drawings and no numbers, so no saved model moves; B (outer face) changes `computeSectionProperties` and no drawings, so new sections lose ~1.8 % of area while stored ones keep theirs (`snapshot` saves A and I rather than rebuilding them). Impact on concrete templates: **numerically none.** Verified over `SECTION_SHAPES` — `C-custom` is the ONLY template declaring `c` or `tl`. But `section-shapes.ts` is the file that HOLDS the concrete templates, so editing it is H1's territory even when the change does not touch a concrete line. Hence a proposal. The tests that move are listed per alternative, with the point that matters: the `2t²` assertion changes meaning under BOTH options — today it says "they differ", afterwards it must say "they do not". That is the test to invert deliberately, and the signal the work was done.
… decided Two proposals, no shared file touched. Both are decisions for common integration; neither is implemented. ## `nonprincipal-axes-warning-proposal.md` The app analyses unsymmetric sections about their GEOMETRIC axes, which are not their principal ones, and says so nowhere. Not a zed problem — it reaches **37 catalogued angles a user can pick today**, and predates both branches. Covers what was asked. The existing L profiles: 37 of them (10 European, 27 IRAM-IAS U 500-558), quantified with a decomposition validated against the catalogue's OWN published values first (area within 1–3 %, Iy within 1.5–5 %), so the conclusion is trustworthy — the true minimum principal inertia of an equal-leg angle is ~40 % of what the app stores as its weak axis, i.e. the stored value is ~2.4× too high, on the unsafe side. The zed: same phenomenon, different origin, and one difference that matters for the wording — a sheeted purlin IS restrained out of plane, which is why practice analyses it about a geometric axis, but the provision defining when that holds is in the absent CIRSOC 303. So the notice cannot say "wrong"; it says which assumption would be needed and that it cannot be cited. Why `Ixy` does not exist: the 2D solver is 3-DOF, so with one bending plane the product of inertia appears in no equation; the 3D solver reused the same section description (A, Iy, Iz, J), which is exactly right for the doubly-symmetric families the catalogue originally had; angles arrived as DATA rather than as a new kind of section, and the table publishes Iy and Iz without anyone having to decide whether they were principal. Geometric vs principal axes explained at the level the notice needs. Confirmed no warning exists anywhere: `section-teaching.ts` decomposes an angle into two rectangles and discusses its SHEAR CENTRE, never its principal axes. That no solver number changes, guaranteed by construction: the notice is a pure function of `Section.shape`, reads nothing the solver produces, adds no field, so the persistence paths and the share codec are untouched. With the test that fixes it — same model before and after, displacements and reactions equal exactly. Where it belongs: five surfaces, ranked by where a user can still act, with the owner of each marked. `PropertyPanel` and `ProSectionsTab` recommended first. `SteelPanel` already does it for the zed and is the working precedent. How not to present it as verification: the prohibitions listed (no `OutcomeBadge`, no `--st-ok`, no ratio, none of the claim words, no census, no certificate) and what it actually is — a note about how the app REPRESENTS the section, not about whether the member passes. Presence AND absence tests, as a table over every `SectionShape` literal so that adding a shape breaks the test rather than defaulting to silence: warn on L, invL and Z; stay quiet on I, H, U, C, T, RHS, rect, CHS. Plus the declared blind spot: `generic` is properties-only with no outline, so the app cannot know — and asserting symmetry it cannot verify would be worse than silence. ## `m2-lip-convention-proposal.md` — outer face adopted, with decisive evidence Upgraded from an open question to a recommendation, on evidence stronger than the 2-to-1 implementation count. **Convention B reproduces the drawn outline exactly.** Polygon moments of the loop `createCShape` actually walks, against what each convention computes, over four sizes: B matches area, Iy AND Iz to machine precision in every case, while today's convention is off by exactly 2t² in area and its reflection in both inertias. So B is the convention under which the calculation and the drawing describe the same object, and there is no third option that also is. **A second inconsistency, found while writing the patch, worse than a t/2 shift.** The guards disagree: `createCShape` draws a PLAIN channel when `lip <= tf`, while `computeSectionProperties` happily computes `2·c·tl` of lip in that range. For `c ≤ tf` the app therefore counts material that the outline omits entirely. B closes it with no new guard, because the useful lip is `c − tf`, which is ≤ 0 exactly when the drawing refuses to draw one. Then the four things asked for: the proposed patch as a diff (verified against those polygon moments, with the two sub-decisions it exposes named rather than taken by omission — the validity bound loosens from `c + tf > h/2` to `c > h/2`, and `c ≤ tf` becomes "plain channel" instead of an error); the tests that flip from "differ" to "coincide", with the `2t²` describe to be inverted and renamed deliberately plus a new polygon-moments-equal-properties test so the unification cannot be undone quietly; the impact on C and Z (identical, and they must move together or the `iy(Z) == iy(C)` identity that validates the whole zed derivation breaks); the notice for H1 (numerically zero — `C-custom` is verifiably the only template declaring `c` or `tl` — but `section-shapes.ts` holds the concrete templates, so it is their territory); and a five-item single-commit procedure with one decisive acceptance criterion.
# Conflicts: # web/src/components/pro/ProConnectionsTab.svelte # web/src/components/pro/generators/ProfileSelectorPanel.svelte # web/src/lib/i18n/locales/pt.ts
Batuis
added a commit
that referenced
this pull request
Aug 26, 2026
Part II of the integrated audit. Records the divergence before touching anything — including that `feat/pro-steel-family`, PR #156's base, was already contained in main, which is why retargeting M1 to main does not grow its diff — the three conflicts merging main into M1 and the two merging M1 into M2, each with its resolution and why. Classifies the seven M1 failures the merge surfaced: four obsolete tests that pinned defects main has since fixed, one documentation item the test itself predicted would come due, one product decision left open, and one that was a stale local WASM artefact rather than any branch's fault — which invalidated earlier local numbers, so every figure here is from after the rebuild. The merge into M2 dropped a declaration and left three references to it; the panel threw at mount and nineteen E2E failures were that one error. Neither the build nor 7970 unit tests saw it.
Batuis
added a commit
that referenced
this pull request
Aug 26, 2026
…t open Part I audits the CI of #156 and #164 and explains every absent or red check rather than reporting a colour. The two findings that change what a green means: - **no `concurrency` block.** M1 has a green run AND a red run on the same sha, one second apart, and `gh pr checks` shows one row per check name — so the red is invisible there and only appears in the rollup. The difference between the two runs is exactly one test, `basic-demos:285`, which neither branch touches: flakiness, evidenced rather than assumed. - **the repository's ruleset declares no required status check at all.** #156 reads BLOCKED because `main` requires one approving review and it has none. CI on this repository is advisory; the missing approval is the only thing stopping a merge. Also recorded: the `@slow` suite and the visual baselines never ran on either PR — both are gated on a `run-e2e` label neither has — there is no typecheck or `svelte-check` job in CI at all although both scripts exist, and `@perf` and `basic-selection-permutations` are covered by nothing. Part II records the three decisions and, explicitly, the two places the implementation diverges from `m1-m2-open-findings-proposals.md`: the inline BUILDER was removed as well as the catalogue, and the auxiliary calculator got a vocabulary of its own rather than the metallic status badge. Both are argued where a reader can disagree with them. Part III is what is not closed. The one worth the space: `.conn-ratio-badge` is CSS that `main` deleted in a commit titled "no green tick for steel", which the union resolution of M1's merge brought back — and which no template applies on any ref, so M1 spent a commit fixing the contrast of rules the browser never paints and a test pins them by reading the CSS text. That is a product question in both directions, so it stays open. Beside it, measured: `svelte-check` reports nothing at all for `ProConnectionsTab.svelte`, proved by adding a deliberately unused selector and getting zero mentions against nine for `ProVerificationTab` in the same run.
Batuis
added a commit
that referenced
this pull request
Aug 27, 2026
`gh pr checks 156` reported all seven green and exited 0. The GitHub UI showed the PR as not passing. Both were true, and the gap between them is the finding. M1's head sha carries **fourteen** check runs, not seven, because two github-actions check suites exist on it. Exactly one run was red — `e2e` from `32921289231` — and `gh pr checks` prints one row per check name showing the latest, so it never appeared there. M2's head has a single suite, which is the whole difference between the two PRs: not the code, the number of suites. The duplicate is dated by the PR timeline. `base_ref_changed` at 02:03:28 — #156 retargeted from `feat/pro-steel-family` to `main` — and two `pull_request` runs created at 02:03:30 and 02:03:31, same head sha, same actor, `run_attempt=1` each. The base change was legitimate; what turned it into two full 28-minute runs is that `ci.yml` has no `concurrency` block, so neither cancelled the other and one of them hit the flake. A third suite, from the `render` app, sits `queued` with zero check runs and keeps the legacy combined status pending. It is identical on M1, M2 **and main**, so it explains nothing — recorded so nobody chases it. Reproduced locally on a worktree of M1's exact sha: `basic-demos:285` passes 3/3 in the repeated file, and the full `--grep @smoke` suite — precisely what CI runs — comes out 336 passed, 0 failed, 1 flaky, 4 skipped, the same shape as CI's green run. It does not reproduce on darwin. The mechanism is in the timings: CI takes 25.4 minutes for what takes 10.5 locally, and the test throws four synthetic canvas clicks hoping one lands on a member. The asymmetry that lets this test go red at all: `:160` declares `test.describe.configure({ retries: 2 })` for exactly this reason, and `:285` — structurally the same canvas-click walkthrough — has none. Deliberately not fixed here: `basic-demos.spec.ts` is byte-identical between M1 and main, and changing it from a steel branch would give M1 ownership of a Basic-mode spec it has no reason to touch. CI was rerun on the same sha with nothing changed: attempt 2 of `32921289231` is green, all fourteen check runs are now success or skipped, both suites conclude success, and #156 stopped showing red without a line of code moving. That is the fourth demonstration of this suite producing both colours on identical input. `concurrency` was NOT added to `ci.yml`, and the reasoning is in §30 along with the patch that would fix it: the workflow is main's infrastructure, and `cancel-in-progress` trades a red for a cancelled run unless the group is chosen deliberately. Nothing was committed to M1 either — any commit moves it off the sha that just went green.
… since b71432c `.conn-ratio-badge` is dead CSS on this branch, and the census says so in every direction: `class="conn-ratio-badge` appears 0 times in `origin/main`, 0 times in this branch before the merge with main, 0 times in the merge, and 0 times now. It was not main that orphaned it. `b71432cd` — this branch's own commit, the one that split *Metallic joints* into four `StageSection` sub-sections — moved the governing demand/capacity ratio to the shell's neutral `badge` prop and deleted the two `<span class="conn-ratio-badge {statusClass(...)}">` that were its only consumers. `statusClass` went with them. Two days later `851fd57b` measured the contrast of the three state rules and rewrote all three, and `steel-surface-colour-rules.test.ts` pinned the result in five assertions: a commit and a test suite about CSS the browser has never painted. `main` reached the same conclusion independently and deleted the block in `2c79ed52` ("no green tick for steel"); resolving `ProConnectionsTab.svelte` by keeping both sides in `9883e2bd` is how it came back. Nothing flagged it because nothing could. Svelte prunes unused selectors and warns, but it stops as soon as a component has a class attribute it cannot read statically, and this one has four (`class="conn-result-card {auxTone(...)}"`). Measured by appending a deliberately dead selector to every styled component and compiling: 30 of 169 are blind the same way, 24 of them under `components/pro/`. `svelte-check` reports what the compiler reports, so its silence on this file was accurate rather than broken. ── What replaces the five assertions ────────────────────────────── Not nothing, because the compiler will not write this guard: - the stylesheet contains no `conn-ratio-badge`, so a union merge cannot revive it unnoticed a second time; - both sub-sections still hand `StageSection` the percentage, so the number the badges carried is still on screen; - no element on the panel carries `st-ok` / `st-warn` / `st-fail`, so a ratio gets no status hue at all — the auxiliary block's own vocabulary (`within / near the limit / over the limit`) stays the only thing entitled to comment on it. The `--st-accent`-on-a-red-tint rule keeps its empty list: the shape of the defect outlives the one rule that had it. The check that selection may still use `--st-accent` is carried over verbatim. No template, no component logic and no token changes. `npm run test` (unit): 7401 passed, 12 skipped, 1 todo.
…rings that were never there
The removal's third assertion asked that no class attribute on the panel carries
`st-ok`, `st-warn` or `st-fail` written out. That is true, and it was true on the
day the badges were on screen too: the spans read
`class="conn-ratio-badge {statusClass(...)}"` and `statusClass` was
``return `st-${s}` ``, so the three strings never appeared in the file. A guard
that its own defect would have passed is not a guard.
It now asserts the shape instead — no helper on this panel turns a status into a
class name — and keeps the written-out check beside it, which covers the other
route. Verified against `b71432cd^`: the new assertion fails there, the old one
passes.
Worth recording because it is the same fact twice. The interpolation that hid the
class name from a grep is the interpolation that stopped Svelte pruning the
rules, which is why the CSS outlived its consumers for a week without a warning.
…anch
`66259cee` replaced the removal's third assertion with a ban on turning a status
into a class name. CI found the problem immediately, and it was right to: on this
branch `statusClass` is still applied to `.conn-result-card` and
`.conn-status-icon` — the pre-M2 auxiliary verdict, green ✓ in `--st-ok`
included. `8e538631` is what retires that, on M2, where
`steel-never-verified` (with the `ProConnectionsTab` exemption removed) and
`metallic-joints.spec.ts` guard it. Removing dead badge CSS is not the commit
entitled to legislate it, and a test both branches run cannot assert a property
only one of them has.
What survives, because it is the claim the removal actually makes: the class name
appears nowhere in the component — stylesheet and class attributes alike, which
are the two ways it comes back — and both sub-sections still hand `StageSection`
the percentage.
The reasoning for what is deliberately NOT asserted is kept in the block comment,
including the second thing the banned assertion got wrong: `statusClass` was
``return `st-${s}` ``, so `st-ok`, `st-warn` and `st-fail` were never in the file
and a grep for them called this component clean on the day the badges were on
screen. The interpolation that hid the class name from a grep is the interpolation
that stopped the compiler pruning the rules. One defect, two symptoms.
`npm run test` (unit) on this branch: 7400 passed, 12 skipped, 1 todo.
… publish `armKinds` and two helpers in `basic-selection.spec.ts` read `aria-checked` off the selection-kind toggles. `SelectionPanel` renders plain `<button aria-pressed>` and not radios-that-become-checkboxes — a deliberate choice the component documents, because a radiogroup owes a roving tabindex and arrow-key movement that the list never implemented. On a button, `aria-checked` is always `null`. So the two reads in `armKinds` inverted each other: the additions loop saw `null`, clicked, and armed a kind; the removals loop saw `null` again on the very same kind and clicked it straight back off. The audit failed on the first two-kind permutation — `elements+nodes` — in both viewports, and reported `["elements"]` where it wanted `["elements","nodes"]`. It had failed that way from the start. This file carries no `@smoke` tag and CI's `e2e` job runs `npx playwright test --grep @smoke`, so the fifteen-permutation audit has never run in any PR. It surfaced now because the whole local suite was run rather than the smoke subset. The two sites in `basic-selection.spec.ts` were passing, and were passing by luck: they arm kinds that happen to start unarmed, so a click on a `null` read was the right click. They move to `aria-pressed` too, because the next person to reorder those tests should not have to rediscover this. No product code changed, and no assertion was weakened: the audit now genuinely walks all fifteen subsets in both viewports — 5.5 s and 7.1 s against the 1.3 s and 3.8 s it took to bail out on the third one.
…ts its own budget `stays free along the building with wall bracing alone` looped over two parameter sets, so one `it` did two shed generations and two full solves — the heaviest pair in the file — inside the 5 s default timeout. 1,65 s on the machine it was written on, and over 5 s on a CI runner inside the 8100-test pool of M2, where it timed out and took the `web` job with it. Split into one test per configuration. Each does one solve: 761 ms and 765 ms, about 15 % of the budget instead of 33 %. Same two assertions on each, no timeout declared, no case dropped — and the failure now names WHICH configuration failed, where the loop reported the timeout against the `it` and left both candidates equally plausible. Measured while looking: `returns a mechanism wearing a number under a load along the building` is 2,89 s, the slowest in the file by a wide margin, and there is nothing to split — it is a single solve, and it is slow because the unbraced shed is near-singular by design, which is the whole point of the assertion. Shrinking the model would change what it measures. Left alone and written down here instead.
…through too `the section walkthrough › advances when the reader clicks the member` captured the canvas box once and then aimed four clicks at it. A click that lands on a member opens the property panel, and that reflows the canvas — so attempts 2 to 4 went to where the canvas used to be, the step never left `pick`, and the failure reported only that `sliders` never arrived. This is the same defect the walkthrough test above it already fixed, and it carries the reason in its own comment: "Measured before each gesture, not once: the walkthrough starts with the Project panel open and frames the model as it goes, so a box captured at the first step sends later clicks to where the canvas used to be." This test kept the single measurement. It failed on CI in the `e2e` job of BOTH #156 and #164, in the same window, and the only delta from the green run before it was a vitest file Playwright does not read. Fixed rather than retried: three consecutive local runs of the spec pass in 9.3 s, 9.5 s and 10.1 s. No retry was added and no timeout was raised. The 15 s poll on `sliders` is untouched — it was never the problem, it was where the four missed clicks became visible.
Batuis
marked this pull request as ready for review
August 27, 2026 19:51
Batuis
added a commit
that referenced
this pull request
Sep 4, 2026
Built from the REAL diff of the three PRs against their bases, not from the earlier handoffs, and with every claim labelled by how well it is known. ## How it was built The three PRs are a stack, so each diff is the span between its base and its head: #156 M1 b579de8..m1 56 files +9,937 / -52 26 production files #164 M2 m1..m2 135 files +24,058 / -1,242 73 production files #183 M3 m2..m3 15 files +1,672 / -47 11 production files Every statement carries a marker: [V] verified against the current code in this session, [M] measured by running something, [H] inherited from a handoff and NOT re-verified. [H] is a lead, not a fact — and where a decision would rest on one, the inventory says to verify it first. ## What it covers The 24 requested blocks, in A (mandatory QA, 48 entries with PR of origin, file, visible name, exact route from 4005, preconditions, steps, expected result, alternative and empty states, persistence, stage interaction, a11y, performance, normative limits, testids and hooks, severity, and M3-or- accepted) · B (recommended) · C (blocking) · D (fit for M3) · E (accepted limitations) · F (code with no user path) · G (a manual checklist in three passes) · H (how future findings enter M3). ## Two inherited claims re-verified rather than repeated Both came from the M2 inventory, both are exactly the kind of claim that ages badly, and both were checked against the code and CONFIRMED: - `SectionShapeBuilder.svelte` is never mounted: `<SectionShapeBuilder` has zero hits, and the 14 references to the name are 13 i18n files plus one comment; - `notVerifiable` for the BOLT GROUP cannot be produced from the UI. It needs threads='excluded' or an untabulated diameter; the panel writes `threads: 'included'` as a fixed literal and the diameter select offers only tabulated values. The weld DOES reach `notVerifiable` (§J.4 base metal), so the state is not dead everywhere — only in the bolts. ## Two corrections to the brief, stated rather than silently absorbed - the suite uses FIVE viewport sizes, not four: 390x780, 390x844, 800x720, 1280x720, 1280x800. 1440 appears nowhere in a spec — only as a CSS value; - `joint.scene.notDesigned` had no translation in any locale until M3, because nothing had ever rendered it. ## Blocking findings: none open The three that existed during the work are recorded as closed, including one that was mine — a false diagnosis of the walkthrough flake, published and then retracted. The criterion is stated so the classification can be argued with: blocking means a correctness error, data loss, a false claim or a broken main flow. UX and debt do not qualify, however severe they look. No correction was implemented, not even the one-line CSS one. No solver, Rust, Cargo or WASM change. No snapshot and no timeout touched. M1 and M2 were not modified.
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.
Continues PR21 (#135) from its final SHA
08917b9f. Draft: backup and review surface, not authorisation to merge.Full account:
docs/handoffs/m1-steel-selectors-and-bracing.md. Scope split for what comes next:docs/handoffs/m1-m2-scope-split.md.What this branch does
The declared grade reaches the metallic surface. PR21 left every
lookupGradecall site passingundefinedwith a comment saying the catalogue was not on the branch. It is. Aluminium separates from steel for the first time, and because that drops rows from a list whose admission rule isisSteel, the inventory now names the non-ferrous members instead of losing them silently.The angle family says which of its two standards each row came from. Eleven IRAM-IAS profiles were filed under EN 10056-1; provenance is read from the source array, never parsed from a name.
Section properties with a basis on every number. Radii of gyration are exact arithmetic; section moduli need a centroid the tables do not publish, so
ccomes from the verified canonical outline and is refused where nothing supports it — half the width of a UPN 200 would overstate Wz by 46%. Torsion is tabulated or absent, never derived. The root radius turned out to mean four different things.A PRO grade picker on a
GradeSourceseam, stating the authority behind each value: product standard, the 45 grades marked typical of the alloy, the derived shear modulus, and thickness bands always shown with the design code that tabulates them.A profile picker filtering by publishing body, design code and depth, with the full property card and a pinned comparison that survives a filter change.
The shed gets a longitudinal load path. Under a load along the building the default shed returned 2.4·10^11 m — not singular, so every
isFinitecheck passed. Restraining translation above the eaves takes the response to exactly zero: the roof slides, because a planar truss with a pin-jointed web has no out-of-plane stiffness. Three members make the path and none makes it alone; the full system reaches 4.4 mm.purlins: falseis answered in both halves.Generated models carry the chosen grade, so
generator.assume.placeholderGradedisappears when it stops being true.The regulation line names its edition and the maturity of the support, both already on the binding.
Five WCAG AA failures found by measuring, three concrete (fixed by H1) and two in this branch's own joints panel:
--st-accentand--st-okon their own tints, at 3.55 and 3.75.Corrected after review — those two were never on screen.
851fd57brepainted.conn-ratio-badge, and a later census showed no template had applied that class sinceb71432cd, this branch's own sub-section split, which had moved the ratio toStageSection's neutralbadgeprop and deleted both spans. So the contrast fix was applied to CSS the browser does not paint, and the rules are now removed instead (6448e89d). The measurement stands for the three concrete failures; the two metallic ones were real defects in dead code. Full account indocs/handoffs/m1-m2-ci-audit-and-three-decisions.md§31, including why nothing flagged it: Svelte stops reporting unused selectors once a component has a class attribute it cannot read statically, which affects 30 of the repository's 169 styled components.Constraints
No Rust, Cargo, WASM, solver or global-analysis change. Concrete baseline digest unchanged at
1bd4d9c1d575b085. Nothing metallic is presented as verified; the four states are intact andsteelCountsAsVerifiedstill returns the literalfalse.No high-risk shared file was edited —
tokens.css,ProPanel,ProRibbon,StageSection,DesignOverview,OutcomeBadge,DesignToolbar,model.svelte.ts,member-context.ts,design-run.svelte.ts,DocumentModel, the main i18n dictionaries and the global PRO tests. Every new string lives inlocales/steel/{es,en,pt}. The one exception is a single value replacement ofconn.gap.aluminium.scope, applied by key name after H1 published its i18n commit, because M1's own change had made that sentence false.Coordination items are documented rather than acted on:
m1-h1-coordination.md,m1-grade-family-contract.md,m1-token-proposal-reconciliation.md, and theinterface Sectionfield proposed in the scope split.Gates
typecheck 479/479 baseline (no new) · unit 7092 pass (re-run at
6448e89d: 7401 pass, 12 skipped, 1 todo) · build project 14 ·npm run build✓ · check:gate ✓ ·E2E_PORTsmoke 284 pass · 57 M1-specific e2e across four specs.E2E_PORTalways explicit: 4173 was occupied by another worktree during this work.Sync with
main, and one thing it surfacedorigin/mainatb579de87is merged in with an explicit merge commit — no rebase, no force-push, no conflicts. It brought the README rewrite, the production favicon, the dev-asset gate andarc_length.rs; none of it is this branch's work and none of it was touched.Running the complete local E2E suite rather than the smoke subset surfaced a spec that had been broken from the start and had never run anywhere.
basic-selection-permutations.spec.tsreads the state of the selection-kind toggles fromaria-checked.SelectionPanelrenders plain<button aria-pressed>and not radios-that-become-checkboxes — a deliberate choice the component documents, because a radiogroup owes a roving tabindex and arrow-key movement the list never implemented. On a button,aria-checkedis alwaysnull, so the two reads inarmKindsinverted each other: the additions loop clicked a kind on, and the removals loop, seeingnullagain on that same kind, clicked it straight back off. The fifteen-permutation audit failed on the first two-kind subset, in both viewports, reporting["elements"]where it wanted["elements","nodes"].It never showed up because this file carries no
@smoketag and CI'se2ejob runsnpx playwright test --grep @smoke. The audit now genuinely walks all fifteen subsets — 5.5 s and 7.1 s against the 1.3 s and 3.8 s it took to bail out on the third one. The two equivalent reads inbasic-selection.spec.tswere passing by luck of ordering and move toaria-pressedas well.No product code changed and no assertion was weakened.
Gates, re-run on the merged head
typecheck 479 = baseline 479, no new errors · unit 7400 pass / 0 fail (398 files, 12 skipped, 1 todo) · build project 19 pass (14 plus the five
mainbrought) ·npm run build✓ · full local E2E 638 pass.Four local E2E failures, none of them this branch's and none of them fixable without breaking a stated constraint:
basic-selection-permutations×2aria-pressedbug aboverc-design-visual › @slow visual baselines (non-blocking)mainor under therun-e2elabel.viewport-perf › @perf › la-bombonera@perfat all.basic-demos › drawing a beamNo snapshot was updated and no timeout was inflated.
Three test fixes, all found by running the gates rather than by reading
None is product code, none weakens an assertion, none is a snapshot update and none raises a timeout.
1 ·
basic-selection-permutations.spec.ts— the audit read an attribute the panel does not publish. It readaria-checkedoff the selection-kind toggles;SelectionPanelrenders plain<button aria-pressed>, deliberately, because a radiogroup owes a roving tabindex the list never implemented. On a buttonaria-checkedis alwaysnull, so the two reads inarmKindsinverted each other: the additions loop armed a kind and the removals loop, seeingnullon that same kind, disarmed it. The fifteen-permutation audit failed on the first two-kind subset in both viewports and had never run anywhere — the file carries no@smoketag and CI'se2ejob runs--grep @smoke. It now genuinely walks all fifteen subsets: 5.5 s and 7.1 s, against the 1.3 s and 3.8 s it took to bail out on the third. The two equivalent reads inbasic-selection.spec.tswere passing by luck of ordering and move too.2 ·
shed-bracing.test.ts— one shed solve per test.stays free along the building with wall bracing alonelooped over two parameter sets, so oneitdid two shed generations and two full solves inside the 5 s default. 1.65 s on the machine it was written on, over 5 s on a CI runner inside M2's 8100-test pool, where it timed out and failed that PR'swebjob. Split into one test per configuration: 761 ms and 765 ms, and the failure now names which configuration failed. #164'swebjob has passed twice consecutively since.3 ·
basic-demos.spec.ts— the canvas box is measured before each click. The section walkthrough captured it once and aimed four clicks at it. The sibling test two functions up already carries exactly this correction and its reason. Real defect, fixed.Correction: fix 3 did not fix the CI flake, and the cause I published for it was wrong
I said the canvas re-measurement would fix the intermittent
e2efailure in that spec. It did not — the next run failed the same assertion at the line the fix moved it to. I then published a diagnosis saying the test skips opening the Section Analysis panel, which thearmstep leaves to the reader. That diagnosis is wrong, and it would send a reader down the wrong path, so it is retracted here rather than left standing.resultsStore.stressQuery— thepickstep's only advance condition — is written in exactly one place for 2D: theViewport.svelteclick handler, whenfindNearestElement(world.x, world.y, 0.3)returns a member. The section panel opens as a consequence of that write, not as a precondition for it. So a closed panel is a symptom, not the cause.What the artefact does establish: the tour sat on
Choose the stationwith its button onWaiting...; the section panel was not open; a probe read a displacement station atx/L = 50.0%; andstressQuerywas null. Two candidate causes survive that evidence and it does not separate them — either the four clicks never landed within the 0.3 m capture radius of a member, or the select mode was not'stress'when they did. I do not know which, and the honest state of this finding is "narrowed, not diagnosed".mainreached the same flake independently in0844f01dand declared retries on this test, citing runs offeat/pro-steel-m2as its evidence and labelling itself, correctly, "containment, not a diagnosis". That containment is not yet in this branch — it arrives whenmainis merged. Until then this test can still redden either PR'se2ejob. It is not a defect in this branch's work.Review summary — scope, limits, and what is deferred
Delivered scope
The metallic PRO foundation: material and section selectors on a
GradeSourceseam, section properties with a declared basis on every number, the shed's longitudinal load path, and the grade/profile pairing. Full account above.Merge order, and the dependency
#156merges first, intomain.#164merges second. #164 is based onfeat/pro-steel-m1and contains this branch by merge — merging #164 before #156, or retargeting it atmain, would carry M1's commits in under M2's review. The two are not independent.Known limitations
steelCountsAsVerified()still returns the literalfalse; nothing metallic is presented as verified anywhere.composition,profileFamily,tl,built. Preexisting, concrete and steel alike, documented indocs/handoffs/share-codec-fields.md§1–§6.built-section-contract.test.tsasserts the loss so it cannot be forgotten.generator.assume.latticeBasesPinnedNoOutOfPlane.Non-blocking findings, for M3
basic-demossection-walkthrough flake, narrowed to two candidate causes (above)mainalready carries containment; CI green on this headreturns a mechanism wearing a number under a load along the buildingis 2.89 s against the 5 s defaulttypecheckand nosvelte-checkjob, noconcurrency, ande2eruns--grep @smokeonlyci.yml. The@smoke-only gap is what hid finding 1 above for as long as it existedmainor 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 CPU contention; 4/4 in isolation@perfNothing classified as blocking
No correctness error, no data loss, no broken main flow. The one item that fell in the false claim category was my own published diagnosis of the flake, and it is retracted above.
Gates on this head
typecheck 479 = baseline 479, no new · unit 7401 pass / 0 fail · build project 19 pass ·
npm run build✓ · full local E2E 638 pass · CIlinttestsuite (1)suite (2)webe2eall pass.mainhas moved on since this branch synced with it (b579de87→c4540883). Deliberately not chased: the branch is frozen. A dry-run merge of the newmainproduces zero conflict markers — the one file both touched,basic-demos.spec.ts, is changed in different regions.