From ad4192e6845e57e6dc72e9eef4645e8699e63b43 Mon Sep 17 00:00:00 2001 From: Bauti Date: Thu, 20 Aug 2026 16:49:37 -0300 Subject: [PATCH 01/36] feat(design): the floor families stop reporting a zero they never counted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FloorFamiliesPanel` computed its family counts as `floorRun?.slabs.length ?? 0`, with the same `?? 0` on walls and `footingRun?.outcomes ?? []` on footings. On a project that had never run the floor pass, that is `0`, rendered in the family tab identically to a real zero. So the panel told an engineer their building had NO SLABS — which reads as a fact about the building and was a fact about the button. ── A count that is not known is null, never 0 ───────────────────── `lib/engine/detailing/floor-family-state.ts` is a pure module: data in, state out, no store and no i18n. Every count it cannot state is `null`, and the markup renders a reason where the figure would go. Seven states, each from a real source: notRun run === null noElements readiness.shellCount === 0 · footingCount === 0 skipped classified in the family, in neither the designed nor the refused set — derived by subtraction, which is the only honest source, because the run publishes no "skipped" list designed run.slabs[] / run.walls[] refused run.unsupported[] — each entry names its element provisional a result whose maturity is not VALIDATED, or whose own unsupported[] is non-empty error store.lastError `noElements` outranks `notRun` on purpose: telling someone their model has no walls is more useful than telling them a pass has not run over walls they do not have. And a missing maturity record reads as provisional, never as designed — the cautious default, never the flattering one. ── Two classifications that were disappearing ───────────────────── `ShellFamily` is `slab | wall | inclined | degenerate`, and this panel had two tabs. An inclined shell — a ramp, a pitched slab — and a degenerate one, whose geometry the classifier could not resolve, were classified by the run and then appeared in NO count: not in `slabs[]`, not in `walls[]`, and not in the refusals unless they happened to raise one. They now have their own figures and an explanation of what each means. ── The headline never hides a limitation ────────────────────────── `kind` is chosen by caution, not by majority. Forty designed panels with one refusal do not report themselves as clean; a family with any provisional result says provisional. Every count travels alongside, so caution does not overstate either. ── What the panel says now ──────────────────────────────────────── Per family: the state as a glyph AND a word (never colour alone), why it is that state, the scope of the last run — or that there was none, rather than a row of zeros — and the next action. Plus one sentence that finally distinguishes the two primary buttons: "Design all" designs the frame and touches no shell or footing; "Design and detail floors" does the opposite. ── Kept inside the repo's own ceiling ───────────────────────────── The additions pushed `FloorFamiliesPanel` to 607 lines and `rc-design-gates.test.ts` caps components at 600. Rather than raise the ceiling, the state block is its own component: panel 515, card 133. Everything in the card is presentation over a value the pure module already computed. ── i18n ─────────────────────────────────────────────────────────── 31 keys under `design.floor.state.*`, in a CONTIGUOUS headed block in en/es/pt. Contiguous because piecemeal insertion into these dictionaries is exactly what produced 64 and then 15 duplicate keys on the last two merges — two independent insertions at the same places, which git accepts without flagging a conflict. The block header tells M1 to add `conn.gap.aluminium.scope` as its own block rather than interleaving. `locale-parity` guards `design.*` across all FOURTEEN dictionaries, not the three offered ones, so the keys exist in the other eleven with English text — the same convention `design.stage.*` already uses. Those locales are not in `OFFERED_LOCALES` and render English regardless; what the gate prevents is a feature going missing in silence. ── Tests ────────────────────────────────────────────────────────── `floor-family-state.test.ts`, 18 unit cases. The load-bearing one sweeps every unknown path and asserts no count is ever `0` — a single `?? 0` reintroduced anywhere is the whole defect back. Plus: "nobody looked" against "we looked and found none", inclined/degenerate not counted as slabs, a null footing check read as a refusal rather than a zero, and a modelled footing absent from the outcomes read as skipped rather than refused. `floor-family-states.spec.ts`, 14 E2E cases at 1280×720: exactly one of a count or a no-figure marker per tab, the state legible with the glyph stripped, the reason present, the scope not printing zeros, a next action always, the two passes distinguished, measured overflow (`scrollWidth ≤ clientWidth`, not eyeballed), tab roles and `aria-selected`, and all of it in es and pt. Nothing outside the concrete surface was touched: no solver, no Rust, no Cargo, no WASM, no analysis, no calculation authority, and no file M1 owns. Gates: unit 370 files / 6928 tests · build tests 14 · production build 14.3 s · typecheck 479 against baseline 479 with no new errors · i18n 161/161 · floor-family-states 14/14 · rc-design-gates green. --- web/e2e/floor-family-states.spec.ts | 179 ++++++++++++ .../pro/design/FloorFamiliesPanel.svelte | 78 ++++- .../pro/design/FloorFamilyStateCard.svelte | 133 +++++++++ .../__tests__/floor-family-state.test.ts | 268 ++++++++++++++++++ .../engine/detailing/floor-family-state.ts | 243 ++++++++++++++++ web/src/lib/i18n/locales/ar.ts | 35 +++ web/src/lib/i18n/locales/de.ts | 35 +++ web/src/lib/i18n/locales/en.ts | 42 +++ web/src/lib/i18n/locales/es.ts | 42 +++ web/src/lib/i18n/locales/fr.ts | 35 +++ web/src/lib/i18n/locales/hi.ts | 35 +++ web/src/lib/i18n/locales/id.ts | 35 +++ web/src/lib/i18n/locales/it.ts | 35 +++ web/src/lib/i18n/locales/ja.ts | 35 +++ web/src/lib/i18n/locales/ko.ts | 35 +++ web/src/lib/i18n/locales/pt.ts | 42 +++ web/src/lib/i18n/locales/ru.ts | 35 +++ web/src/lib/i18n/locales/tr.ts | 35 +++ web/src/lib/i18n/locales/zh.ts | 35 +++ 19 files changed, 1403 insertions(+), 9 deletions(-) create mode 100644 web/e2e/floor-family-states.spec.ts create mode 100644 web/src/components/pro/design/FloorFamilyStateCard.svelte create mode 100644 web/src/lib/engine/detailing/__tests__/floor-family-state.test.ts create mode 100644 web/src/lib/engine/detailing/floor-family-state.ts diff --git a/web/e2e/floor-family-states.spec.ts b/web/e2e/floor-family-states.spec.ts new file mode 100644 index 000000000..0d18a6b1b --- /dev/null +++ b/web/e2e/floor-family-states.spec.ts @@ -0,0 +1,179 @@ +/** + * The floor families say what they know, and admit what they do not. + * + * ── The defect on screen ─────────────────────────────────────────── + * + * The three family tabs carried a bare number, computed as `floorRun?.slabs.length ?? 0`. On a + * project that had never run the floor pass that number was `0`, rendered identically to a real + * zero. So the panel told an engineer their building had **no slabs**, which reads as a fact + * about the building and was a fact about the button. + * + * These run at 1280×720 — the width the PRO panel is tightest at — and assert the distinction a + * number cannot carry: that "nobody looked" and "we looked and found none" are different states + * with different words, and that a count which is not known is not printed as zero. + */ + +import { test, expect } from './fixtures'; +import type { Page } from '@playwright/test'; + +test.use({ viewport: { width: 1280, height: 720 } }); + +/** Reach Design → the floors stage, through the ribbon a user has. */ +async function openFloors(page: Page) { + await page.getByTestId('pr-stage-design').click(); + await page.getByTestId('pr-cmd-design').click(); + const section = page.getByTestId('floor-families'); + // The stage lives inside a disclosure; open it if it is closed. + if (!(await section.isVisible().catch(() => false))) { + await page.getByText(/slabs, walls|losas, tabiques|lajes, paredes/i).first().click(); + } + await expect(section).toBeVisible(); +} + +const tab = (page: Page, fam: string) => page.getByTestId(`floor-family-${fam}`); + +test.describe('@smoke a family with no run prints no figure', () => { + test('the tab shows a dash and a state word, never a zero', async ({ pro: page }) => { + await openFloors(page); + for (const fam of ['slabs', 'walls', 'foundations']) { + const state = await tab(page, fam).getByTestId(`floor-family-${fam}-state`).innerText(); + // Whatever the state is, it is a WORD. The old panel had only a number here. + expect(state.trim().length, fam).toBeGreaterThan(1); + // And where no figure can be stated, none is printed. + const count = tab(page, fam).getByTestId(`floor-family-${fam}-count`); + const nofig = tab(page, fam).getByTestId(`floor-family-${fam}-nofigure`); + const hasCount = await count.count(); + const hasNofig = await nofig.count(); + expect(hasCount + hasNofig, `${fam}: exactly one of count/no-figure`).toBe(1); + if (hasCount) { + // A printed number must not be the fabricated zero: it only appears once a run exists. + await expect(page.getByTestId('floor-family-state')).not.toHaveAttribute('data-state', 'notRun'); + } + } + }); + + test('the state carries a glyph AND a word, so colour is only support', async ({ pro: page }) => { + await openFloors(page); + const badge = page.getByTestId('floor-state-badge'); + const text = (await badge.innerText()).trim(); + // Strip the glyph and there must still be a word left. + expect(text.replace(/[·—○✓✕⚗]/g, '').trim().length).toBeGreaterThan(1); + }); + + test('and it explains WHY there is no figure', async ({ pro: page }) => { + await openFloors(page); + const why = page.getByTestId('floor-state-why'); + await expect(why).toBeVisible(); + expect((await why.innerText()).trim().length).toBeGreaterThan(30); + }); +}); + +test.describe('@smoke scope and next step are stated, not implied', () => { + test('the scope says there was no run rather than printing a row of zeros', + async ({ pro: page }) => { + await openFloors(page); + const scope = await page.getByTestId('floor-state-scope').innerText(); + // A run that never happened has no classified/designed/refused figures to show. + expect(scope).not.toMatch(/\b0\b.*\b0\b/); + }); + + test('every state recommends a next action', async ({ pro: page }) => { + await openFloors(page); + const next = page.getByTestId('floor-state-next'); + await expect(next).toBeVisible(); + expect((await next.innerText()).trim().length).toBeGreaterThan(10); + }); + + test('the panel distinguishes Design all from Design floors', async ({ pro: page }) => { + await openFloors(page); + const vs = page.getByTestId('floor-scope-vs-all'); + await expect(vs).toBeVisible(); + // Both passes named, and what each one leaves alone. + await expect(vs).toContainText(/frame|pórtico|pilares/i); + await expect(vs).toContainText(/shell|casca|cáscara/i); + }); +}); + +test.describe('the state block follows the selected family', () => { + test('switching tabs changes the state that is described', async ({ pro: page }) => { + await openFloors(page); + const shown = async () => page.getByTestId('floor-family-state').getAttribute('data-state'); + + await tab(page, 'slabs').click(); + const slabState = await shown(); + await tab(page, 'foundations').click(); + const foundState = await shown(); + + // Both are real states, and the block is not stuck on the first family. + for (const s of [slabState, foundState]) { + expect(['error', 'notRun', 'noElements', 'skipped', 'designed', 'refused', 'provisional']) + .toContain(s); + } + // A model with no footings and shells present must differ between the two. + const perFamilyStatesDiffer = slabState !== foundState; + const bothNoElements = slabState === 'noElements' && foundState === 'noElements'; + expect(perFamilyStatesDiffer || bothNoElements).toBe(true); + }); +}); + +test.describe('layout and accessibility at 1280×720', () => { + test('nothing in the panel overflows its width', async ({ pro: page }) => { + await openFloors(page); + // Measured, not eyeballed — the same rule PR20's own spec uses for this panel. + const overflow = await page.getByTestId('floor-families').evaluate((el) => { + const bad: string[] = []; + for (const n of [el, ...el.querySelectorAll('*')]) { + const e = n as HTMLElement; + if (e.scrollWidth > e.clientWidth + 1 && e.clientWidth > 0) { + bad.push(`${e.tagName}.${e.className}`.slice(0, 60)); + } + } + return bad; + }); + expect(overflow).toEqual([]); + }); + + test('the tabs keep their roles and selected state', async ({ pro: page }) => { + await openFloors(page); + for (const fam of ['slabs', 'walls', 'foundations']) { + await expect(tab(page, fam)).toHaveAttribute('role', 'tab'); + } + await tab(page, 'walls').click(); + await expect(tab(page, 'walls')).toHaveAttribute('aria-selected', 'true'); + await expect(tab(page, 'slabs')).toHaveAttribute('aria-selected', 'false'); + }); + + test('the no-figure marker explains itself on hover as well as in the block', + async ({ pro: page }) => { + await openFloors(page); + const nofig = tab(page, 'slabs').getByTestId('floor-family-slabs-nofigure'); + if (await nofig.count()) { + // A title is not the only explanation — the block below carries it too — but the + // marker must not be a bare dash with no account of itself. + expect(await nofig.getAttribute('title')).toBeTruthy(); + } + }); +}); + +for (const [locale, words] of [ + ['es', { notRun: /sin ejecutar|sin elementos/i, why: /no se corrió|no tiene elementos/i }], + ['pt', { notRun: /não executado|sem elementos/i, why: /não foi executado|não tem elementos/i }], +] as const) { + test.describe(`the states are legible in ${locale}`, () => { + test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } }); + + test('state word and reason are translated', async ({ pro: page }) => { + await openFloors(page); + await expect(page.getByTestId('floor-state-badge')).toContainText(words.notRun); + await expect(page.getByTestId('floor-state-why')).toContainText(words.why); + }); + + test('the Design-all distinction is translated too', async ({ pro: page }) => { + await openFloors(page); + const vs = await page.getByTestId('floor-scope-vs-all').innerText(); + // A cheap tripwire for a key that fell back to English rather than being translated. + expect(vs).not.toMatch(/^"Design all"/); + expect(vs.trim().length).toBeGreaterThan(60); + }); + }); +} diff --git a/web/src/components/pro/design/FloorFamiliesPanel.svelte b/web/src/components/pro/design/FloorFamiliesPanel.svelte index f9edc5187..ea93914f7 100644 --- a/web/src/components/pro/design/FloorFamiliesPanel.svelte +++ b/web/src/components/pro/design/FloorFamiliesPanel.svelte @@ -24,6 +24,11 @@ import { modelStore } from '../../../lib/store/model.svelte'; import { regulationsStore } from '../../../lib/store/regulations.svelte'; import FoundationsPanel from './FoundationsPanel.svelte'; + import FloorFamilyStateCard from './FloorFamilyStateCard.svelte'; + import { + floorFamilyStates, offFamilyShells, + type FloorFamilyKey, type FloorFamilyState, + } from '../../../lib/engine/detailing/floor-family-state'; type Family = 'slabs' | 'walls' | 'foundations'; let family = $state('slabs'); @@ -37,11 +42,42 @@ const concreteCode = $derived(regulationsStore.concreteDesignCode()); const concreteProblem = $derived(regulationsStore.concreteDesignProblem()); - const slabCount = $derived(floorRun?.slabs.length ?? 0); - const wallCount = $derived(floorRun?.walls.length ?? 0); - const checkedFootings = $derived( - (footingRun?.outcomes ?? []).filter((o) => o.check !== null).length, - ); + /** + * The per-family state, from real sources only. + * + * This replaces `floorRun?.slabs.length ?? 0` and its two siblings. That `?? 0` rendered a + * hard zero in the family tab whenever no run had happened, so a project that had never + * been through the floor pass reported that it had NO SLABS — which reads as a fact about + * the building and was a fact about the button. `floor-family-state.ts` returns `null` for + * a count it cannot state, and the markup renders a reason in its place. + */ + const famStates = $derived(floorFamilyStates({ + run: floorRun, + readiness: { shellCount: readiness.shellCount }, + footingCount, + footingRun, + error: detailingStore.lastError, + })); + const stateOf = $derived((k: FloorFamilyKey) => famStates.find((f) => f.family === k)!); + /** Shells classified as neither slab nor wall — invisible before this. */ + const offFamily = $derived(offFamilyShells({ + run: floorRun, + readiness: { shellCount: readiness.shellCount }, + footingCount, + footingRun, + error: detailingStore.lastError, + })); + + /** Glyph per state, so the state is never carried by colour alone. */ + const GLYPH: Record = { + error: '✕', notRun: '·', noElements: '—', skipped: '○', + designed: '✓', refused: '✕', provisional: '⚗', + }; + + const checkedFootings = $derived(stateOf('foundations').designed ?? 0); + + /** The selected family's state. A `$derived`, because `{@const}` may only sit inside a block. */ + const st = $derived(stateOf(family)); /** * The punching joints of each panel, keyed by panel id. @@ -165,17 +201,40 @@ + + + {#if family === 'slabs'} {@const slabs = floorRun?.slabs ?? []} {#if slabs.length === 0} @@ -388,6 +447,7 @@ diff --git a/web/src/lib/engine/detailing/__tests__/floor-family-state.test.ts b/web/src/lib/engine/detailing/__tests__/floor-family-state.test.ts new file mode 100644 index 000000000..70530dc0a --- /dev/null +++ b/web/src/lib/engine/detailing/__tests__/floor-family-state.test.ts @@ -0,0 +1,268 @@ +/** + * The seven states, and the zero that must never be invented. + * + * ── What these are written against ───────────────────────────────── + * + * `FloorFamiliesPanel` computed its family counts as `floorRun?.slabs.length ?? 0`. With no + * run that is `0`, rendered in the tab exactly like a real zero — so a project that had never + * been through the floor pass reported that it had NO SLABS. That is a statement about the + * building, and it was a statement about the button. + * + * So the load-bearing assertion in this file is not "the count is right". It is that an + * unknown count is `null` and never `0`, and that the two readings a `0` used to conflate — + * "nobody looked" and "we looked and found none" — are now different states with different + * words. + */ + +import { describe, it, expect } from 'vitest'; +import { + floorFamilyStates, offFamilyShells, + type FloorFamilyInput, type FloorFamilyKey, +} from '../floor-family-state'; + +const VALIDATED = { maturity: { level: 'VALIDATED' }, unsupported: [] }; + +/** A run that classified nothing and designed nothing, for building up from. */ +const emptyRun = { + slabs: [], walls: [], classifications: [], unsupported: [], +}; + +function input(over: Partial = {}): FloorFamilyInput { + return { + run: null, + readiness: { shellCount: 0 }, + footingCount: 0, + footingRun: null, + error: null, + ...over, + }; +} + +const of = (r: ReturnType, k: FloorFamilyKey) => + r.find((x) => x.family === k)!; + +describe('no figure is invented before the pass classifies anything', () => { + it('a model with shells and no run reports notRun, with every count null', () => { + const r = floorFamilyStates(input({ readiness: { shellCount: 12 } })); + const slabs = of(r, 'slabs'); + expect(slabs.kind).toBe('notRun'); + // The whole point: null, not 0. + expect(slabs.classified).toBeNull(); + expect(slabs.designed).toBeNull(); + expect(slabs.refused).toBeNull(); + expect(slabs.skipped).toBeNull(); + expect(slabs.countsUnavailable).toBe(true); + }); + + it('never returns 0 for a count it cannot state', () => { + // Swept across every family and every unknown path, because a single `?? 0` reintroduced + // anywhere is the entire defect back. + for (const inp of [ + input({ readiness: { shellCount: 5 } }), // notRun + input({ readiness: { shellCount: 0 } }), // noElements + input({ readiness: { shellCount: 5 }, error: 'boom' }), // error + ]) { + for (const st of floorFamilyStates(inp)) { + for (const k of ['classified', 'designed', 'refused', 'skipped'] as const) { + expect(st[k], `${st.family}.${k} on ${st.kind}`).not.toBe(0); + expect(st[k], `${st.family}.${k} on ${st.kind}`).toBeNull(); + } + } + } + }); + + it('distinguishes "nobody looked" from "we looked and found none"', () => { + const notRun = of(floorFamilyStates(input({ readiness: { shellCount: 4 } })), 'slabs'); + const lookedAndFoundNone = of(floorFamilyStates(input({ + readiness: { shellCount: 4 }, + run: { ...emptyRun, classifications: [{ elementId: 1, family: 'wall' }] }, + })), 'slabs'); + + expect(notRun.kind).toBe('notRun'); + expect(notRun.classified).toBeNull(); + // A real zero: the run classified, and none of them was a slab. + expect(lookedAndFoundNone.classified).toBe(0); + expect(lookedAndFoundNone.countsUnavailable).toBe(false); + }); +}); + +describe('sin elementos — a fact about the model, not about the run', () => { + it('reports noElements for shells with no run at all', () => { + // Outranks notRun on purpose: telling someone their model has no walls is more useful + // than telling them a pass has not run over the walls they do not have. + const r = floorFamilyStates(input({ readiness: { shellCount: 0 } })); + expect(of(r, 'slabs').kind).toBe('noElements'); + expect(of(r, 'walls').kind).toBe('noElements'); + }); + + it('reports noElements for foundations when none are modelled', () => { + expect(of(floorFamilyStates(input({ footingCount: 0 })), 'foundations').kind) + .toBe('noElements'); + }); + + it('and does NOT report noElements when footings exist but no run has happened', () => { + const f = of(floorFamilyStates(input({ footingCount: 3 })), 'foundations'); + expect(f.kind).toBe('notRun'); + expect(f.designed).toBeNull(); + }); +}); + +describe('designed, refused, skipped and provisional come from the run', () => { + const threeSlabs = { + ...emptyRun, + classifications: [ + { elementId: 1, family: 'slab' as const }, + { elementId: 2, family: 'slab' as const }, + { elementId: 3, family: 'slab' as const }, + ], + }; + + it('designed counts the results, and clears when nothing is outstanding', () => { + const r = floorFamilyStates(input({ + readiness: { shellCount: 3 }, + run: { ...threeSlabs, slabs: [VALIDATED, VALIDATED, VALIDATED] }, + })); + const s = of(r, 'slabs'); + expect(s.kind).toBe('designed'); + expect(s.designed).toBe(3); + expect(s.refused).toBe(0); + expect(s.skipped).toBe(0); + }); + + it('refused names the elements the pass stopped on', () => { + const r = floorFamilyStates(input({ + readiness: { shellCount: 3 }, + run: { ...threeSlabs, unsupported: [{ elementId: 1 }, { elementId: 2 }] }, + })); + const s = of(r, 'slabs'); + expect(s.refused).toBe(2); + // Nothing designed, so a refusal is the headline rather than a footnote. + expect(s.kind).toBe('refused'); + }); + + it('skipped is what was classified and then neither designed nor refused', () => { + const r = floorFamilyStates(input({ + readiness: { shellCount: 3 }, + run: { ...threeSlabs, slabs: [VALIDATED], unsupported: [{ elementId: 2 }] }, + })); + const s = of(r, 'slabs'); + // 3 classified − 1 designed − 1 refused = 1 outside the run's scope. + expect(s.skipped).toBe(1); + }); + + it('provisional is a design that is not complete, and it outranks designed', () => { + // Unvalidated maturity. + const a = of(floorFamilyStates(input({ + readiness: { shellCount: 1 }, + run: { + ...emptyRun, + classifications: [{ elementId: 1, family: 'slab' }], + slabs: [{ maturity: { level: 'ESTIMATED' }, unsupported: [] }], + }, + })), 'slabs'); + expect(a.provisional).toBe(1); + expect(a.kind).toBe('provisional'); + + // Or a design naming conditions it could not cover. + const b = of(floorFamilyStates(input({ + readiness: { shellCount: 1 }, + run: { + ...emptyRun, + classifications: [{ elementId: 1, family: 'slab' }], + slabs: [{ maturity: { level: 'VALIDATED' }, unsupported: ['no punching data'] }], + }, + })), 'slabs'); + expect(b.provisional).toBe(1); + expect(b.kind).toBe('provisional'); + }); + + it('missing maturity is provisional, not designed', () => { + // The cautious default. An absent record must never read as a validated one. + const s = of(floorFamilyStates(input({ + readiness: { shellCount: 1 }, + run: { ...emptyRun, classifications: [{ elementId: 1, family: 'slab' }], slabs: [{}] }, + })), 'slabs'); + expect(s.kind).toBe('provisional'); + }); + + it('a mostly-designed family with one refusal does not report itself as clean', () => { + // The failure mode: 40 designed and 1 refused reading as "designed" and burying the one + // thing a reviewer has to look at. + const s = of(floorFamilyStates(input({ + readiness: { shellCount: 3 }, + run: { ...threeSlabs, slabs: [VALIDATED, VALIDATED], unsupported: [{ elementId: 3 }] }, + })), 'slabs'); + expect(s.designed).toBe(2); + expect(s.refused).toBe(1); + expect(s.kind).not.toBe('designed'); + }); +}); + +describe('inclined and degenerate shells are not dropped', () => { + const run = { + ...emptyRun, + classifications: [ + { elementId: 1, family: 'slab' as const }, + { elementId: 2, family: 'inclined' as const }, + { elementId: 3, family: 'inclined' as const }, + { elementId: 4, family: 'degenerate' as const }, + ], + slabs: [VALIDATED], + }; + + it('reports them separately from slabs, walls and refusals', () => { + // Before this they were in no count anywhere: not in slabs[], not in walls[], and not in + // the refusals unless they happened to raise one. + const off = offFamilyShells(input({ readiness: { shellCount: 4 }, run }))!; + expect(off.inclined).toBe(2); + expect(off.degenerate).toBe(1); + expect(off.total).toBe(3); + }); + + it('does not count them as slabs or walls', () => { + const r = floorFamilyStates(input({ readiness: { shellCount: 4 }, run })); + expect(of(r, 'slabs').classified).toBe(1); + expect(of(r, 'walls').classified).toBe(0); + }); + + it('is null with no run — the same rule as every other count', () => { + expect(offFamilyShells(input({ readiness: { shellCount: 4 } }))).toBeNull(); + }); +}); + +describe('an error outranks every figure', () => { + it('reports error for every family and states no counts', () => { + // Figures on hand belong to the previous run. Showing them beside a failure would present + // stale numbers as current ones. + const r = floorFamilyStates(input({ + readiness: { shellCount: 9 }, footingCount: 4, error: 'floor pass threw', + run: { ...emptyRun, classifications: [{ elementId: 1, family: 'slab' }], slabs: [VALIDATED] }, + })); + for (const st of r) { + expect(st.kind, st.family).toBe('error'); + expect(st.classified, st.family).toBeNull(); + expect(st.countsUnavailable, st.family).toBe(true); + } + }); +}); + +describe('foundations read their own gate', () => { + it('a null check is a refusal, not a zero', () => { + const f = of(floorFamilyStates(input({ + footingCount: 3, + footingRun: { outcomes: [{ check: {} }, { check: null }, { check: null }] }, + })), 'foundations'); + expect(f.designed).toBe(1); + expect(f.refused).toBe(2); + }); + + it('a modelled footing absent from the outcomes is skipped, not refused', () => { + const f = of(floorFamilyStates(input({ + footingCount: 5, + footingRun: { outcomes: [{ check: {} }, { check: {} }] }, + })), 'foundations'); + expect(f.designed).toBe(2); + expect(f.refused).toBe(0); + expect(f.skipped).toBe(3); + }); +}); diff --git a/web/src/lib/engine/detailing/floor-family-state.ts b/web/src/lib/engine/detailing/floor-family-state.ts new file mode 100644 index 000000000..d292e68b2 --- /dev/null +++ b/web/src/lib/engine/detailing/floor-family-state.ts @@ -0,0 +1,243 @@ +/** + * What the floor pass actually knows about each family — and what it does not. + * + * ── The defect this exists to remove ─────────────────────────────── + * + * `FloorFamiliesPanel` read its counts as `floorRun?.slabs.length ?? 0`. With no run, that is + * `0`, and `0` is rendered in the family tab exactly the way a real zero is. So a project that + * had never been through the floor pass told the engineer it had **no slabs**, which is + * indistinguishable from "the pass ran and found none" and is the more alarming of the two + * readings. The same `?? 0` was on walls, and `footingRun?.outcomes ?? []` did it for footings. + * + * A count that is not known is `null` here, never `0`. The caller renders an explanation in + * its place. That is the whole point of this module: the absence of a run is a STATE, not a + * quantity. + * + * ── Where each state comes from ──────────────────────────────────── + * + * notRun `run === null` — the pass has not produced a result + * noElements `readiness.shellCount === 0` — model fact, knowable WITHOUT running + * `footingCount === 0` + * skipped classified in the family, and in neither the designed nor the refused set + * designed `run.slabs[]` / `run.walls[]` — real results with layers and shear + * refused `run.unsupported[]` — each entry names its element + * provisional a designed result whose `maturity` is not validated, or whose own + * `unsupported[]` is non-empty — it designed, and not completely + * error `store.lastError` — the pass threw + * + * ── Two classifications nobody was showing ──────────────────────── + * + * `ShellFamily` is `'slab' | 'wall' | 'inclined' | 'degenerate'`. The panel had a tab for + * slabs, a tab for walls, and nowhere for the other two: an inclined shell — a ramp, a stair + * soffit, a pitched roof slab — and a degenerate one, whose geometry the classifier could not + * resolve. Both were classified by the run and then vanished from every count, because they + * are in neither `slabs[]` nor `walls[]` and, unless they happened to raise an `unsupported`, + * in nothing else either. + * + * They are reported here as their own figures. A shell the app cannot design is a fact the + * engineer needs; silently dropping it is the failure mode this module is written against. + * + * Pure: no store, no runes, no i18n. The caller supplies the data and words the result. + */ + +import type { ShellFamily } from './run-floor-design'; + +export type FloorFamilyKey = 'slabs' | 'walls' | 'foundations'; + +export type FloorFamilyStateKind = + /** The pass threw. Any figures on hand belong to an earlier run. */ + | 'error' + /** The model has nothing of this family. Known without running. */ + | 'noElements' + /** No run has classified anything yet. Counts are unknown, not zero. */ + | 'notRun' + /** Designed, and something about it is incomplete. */ + | 'provisional' + /** The pass refused at least one member and designed none. */ + | 'refused' + /** Classified, and neither designed nor refused — out of the run's scope. */ + | 'skipped' + /** Designed, with nothing outstanding. */ + | 'designed'; + +export interface FloorFamilyState { + family: FloorFamilyKey; + /** + * The headline state, chosen by CAUTION rather than by majority. + * + * A family with forty designed panels and one refusal reports `refused` in its detail + * counts and keeps `designed` as its headline only when nothing is outstanding. The + * ordering below never lets a success hide a limitation, and never lets one refusal + * describe a floor that mostly worked — which is why every count travels with it. + */ + kind: FloorFamilyStateKind; + /** + * Members of this family the run classified. `null` when no run has happened. + * + * NEVER `0` for "unknown". A `0` here means the run looked and found none. + */ + classified: number | null; + designed: number | null; + refused: number | null; + provisional: number | null; + skipped: number | null; + /** Shells classified as neither slab nor wall. Reported, never dropped. */ + inclined: number | null; + degenerate: number | null; + /** True when a count cannot be stated yet, so the caller renders a reason instead. */ + countsUnavailable: boolean; +} + +/** A designed result carries enough to say whether it is complete. */ +export interface DesignedProbe { + /** `MaturityRecord.level`, or whatever the record calls its verdict. */ + maturity?: { level?: string } | null; + /** Conditions the design itself could not cover. */ + unsupported?: readonly string[]; +} + +export interface FloorFamilyInput { + run: { + slabs: readonly DesignedProbe[]; + walls: readonly DesignedProbe[]; + classifications: readonly { elementId: number; family: ShellFamily }[]; + unsupported: readonly { elementId: number }[]; + } | null; + /** Model census. `shellCount` is knowable with no run at all. */ + readiness: { shellCount: number }; + footingCount: number; + footingRun: { outcomes: readonly { check: unknown }[] } | null; + /** + * The store's last error. + * + * NOTE — this channel is shared with the beam/column pass: `detailingStore.lastError` is + * written by `generate()` too. So an error raised by a beam run will colour the floor + * families until the next floor run clears it. Attributing it precisely needs a per-pass + * error on the store, which is a store change and is recorded as debt rather than guessed + * at here. + */ + error: string | null; +} + +/** A design that is not fully validated, or that names conditions it could not cover. */ +function isProvisional(d: DesignedProbe): boolean { + if (d.unsupported && d.unsupported.length > 0) return true; + const level = d.maturity?.level; + // Absent or non-validated maturity is provisional. Only an explicit VALIDATED clears it — + // the default must be the cautious reading, never the flattering one. + return level == null || level !== 'VALIDATED'; +} + +function headline(s: { + designed: number; refused: number; provisional: number; skipped: number; +}): FloorFamilyStateKind { + if (s.provisional > 0) return 'provisional'; + if (s.refused > 0 && s.designed === 0) return 'refused'; + if (s.designed === 0 && s.skipped > 0) return 'skipped'; + if (s.designed > 0) return s.refused > 0 ? 'provisional' : 'designed'; + return 'skipped'; +} + +function shellFamily(key: FloorFamilyKey): ShellFamily | null { + return key === 'slabs' ? 'slab' : key === 'walls' ? 'wall' : null; +} + +/** + * The state of one shell family — slabs or walls. + * + * `noElements` is decided from the MODEL, before any run, because "this building has no + * walls" is a fact about the building and does not need a design pass to be true. It + * therefore outranks `notRun`: telling someone their model has no walls is more useful than + * telling them a pass has not run over the walls they do not have. + */ +function shellState(key: 'slabs' | 'walls', input: FloorFamilyInput): FloorFamilyState { + const empty = { + family: key, classified: null, designed: null, refused: null, provisional: null, + skipped: null, inclined: null, degenerate: null, countsUnavailable: true, + } as const; + + if (input.error) return { ...empty, kind: 'error' }; + if (input.readiness.shellCount === 0) return { ...empty, kind: 'noElements' }; + if (!input.run) return { ...empty, kind: 'notRun' }; + + const fam = shellFamily(key)!; + const inFamily = input.run.classifications.filter((c) => c.family === fam); + const refusedIds = new Set(input.run.unsupported.map((u) => u.elementId)); + const results = key === 'slabs' ? input.run.slabs : input.run.walls; + + const classified = inFamily.length; + const designed = results.length; + const refused = inFamily.filter((c) => refusedIds.has(c.elementId)).length; + const provisional = results.filter(isProvisional).length; + // What the run classified into this family and then neither designed nor refused. Derived + // by subtraction because that is the only honest source: the run does not publish a + // "skipped" list, and inventing one would be the same sin as the zero this replaces. + const skipped = Math.max(0, classified - designed - refused); + + return { + family: key, + kind: headline({ designed, refused, provisional, skipped }), + classified, designed, refused, provisional, skipped, + inclined: input.run.classifications.filter((c) => c.family === 'inclined').length, + degenerate: input.run.classifications.filter((c) => c.family === 'degenerate').length, + countsUnavailable: false, + }; +} + +/** + * The state of the foundations family. + * + * Footings do not go through shell classification: they are modelled objects with their own + * per-footing gate, and `footingRun.outcomes` is one entry each. `check === null` is the + * engine saying it could not check that footing — a refusal, not a zero. + */ +function foundationState(input: FloorFamilyInput): FloorFamilyState { + const empty = { + family: 'foundations' as const, classified: null, designed: null, refused: null, + provisional: null, skipped: null, inclined: null, degenerate: null, + countsUnavailable: true, + } as const; + + if (input.error) return { ...empty, kind: 'error' }; + if (input.footingCount === 0) return { ...empty, kind: 'noElements' }; + if (!input.footingRun) return { ...empty, kind: 'notRun' }; + + const outcomes = input.footingRun.outcomes; + const designed = outcomes.filter((o) => o.check !== null).length; + const refused = outcomes.length - designed; + // Modelled footings the run never reported on. Not zero-filled: a footing absent from the + // outcomes was outside the run's scope, and that is a different fact from being refused. + const skipped = Math.max(0, input.footingCount - outcomes.length); + + return { + family: 'foundations', + kind: headline({ designed, refused, provisional: 0, skipped }), + classified: input.footingCount, + designed, refused, provisional: 0, skipped, + inclined: null, degenerate: null, + countsUnavailable: false, + }; +} + +export function floorFamilyStates(input: FloorFamilyInput): FloorFamilyState[] { + return [ + shellState('slabs', input), + shellState('walls', input), + foundationState(input), + ]; +} + +/** + * Shells the run classified as neither slab nor wall. + * + * Surfaced separately because they belong to no tab and were therefore invisible. `null` when + * no run has classified anything — the same rule as every other count here. + */ +export function offFamilyShells(input: FloorFamilyInput): { + inclined: number; degenerate: number; total: number; +} | null { + if (!input.run) return null; + const inclined = input.run.classifications.filter((c) => c.family === 'inclined').length; + const degenerate = input.run.classifications.filter((c) => c.family === 'degenerate').length; + return { inclined, degenerate, total: inclined + degenerate }; +} diff --git a/web/src/lib/i18n/locales/ar.ts b/web/src/lib/i18n/locales/ar.ts index d62b980a6..8bd82db61 100644 --- a/web/src/lib/i18n/locales/ar.ts +++ b/web/src/lib/i18n/locales/ar.ts @@ -3415,6 +3415,41 @@ const ar: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/de.ts b/web/src/lib/i18n/locales/de.ts index 81d42b98b..ae8b574db 100644 --- a/web/src/lib/i18n/locales/de.ts +++ b/web/src/lib/i18n/locales/de.ts @@ -3435,6 +3435,41 @@ const de: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/en.ts b/web/src/lib/i18n/locales/en.ts index 8807152fd..af270f549 100644 --- a/web/src/lib/i18n/locales/en.ts +++ b/web/src/lib/i18n/locales/en.ts @@ -5969,6 +5969,48 @@ const en: Record = { 'detailing.floorRun.next': 'Run the coordinated detailing, which coordinates whatever bars exist by then. The 3-D view and the documents are projections of that result.', 'detailing.floorRun.runningNote': 'Running the whole building. This pass cannot be interrupted.', 'detailing.floorRun.whenToRun': 'Optional, and it runs BEFORE detailing. "Design all" designs the frame — columns and beams; this designs the slabs and walls it carries, and the footings if you ask for them. A frame-only building can skip it.', + // ════════════════════════════════════════════════════════════════════════ + // H1 · design.floor.state.* — honest states for the floor families + // + // A contiguous block on purpose. Keys of one namespace inserted piecemeal + // are what produced 64 and then 15 duplicates when branches merged: two + // independent insertions at the same places, which git accepts without + // flagging a conflict. A block reads as one insertion. + // + // M1 will need these dictionaries for `conn.gap.aluminium.scope`. Add that + // as ANOTHER headed block, not interleaved here. + // ════════════════════════════════════════════════════════════════════════ + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.memo.flexure': 'Flexure', 'design.memo.shear': 'Shear', diff --git a/web/src/lib/i18n/locales/es.ts b/web/src/lib/i18n/locales/es.ts index a4fbd26d4..b003319c2 100644 --- a/web/src/lib/i18n/locales/es.ts +++ b/web/src/lib/i18n/locales/es.ts @@ -5961,6 +5961,48 @@ const es: Record = { 'detailing.floorRun.next': 'Corré el detallado coordinado, que coordina las barras que existan para entonces. La vista 3D y los documentos son proyecciones de ese resultado.', 'detailing.floorRun.runningNote': 'Corriendo todo el edificio. Esta pasada no se puede interrumpir.', 'detailing.floorRun.whenToRun': 'Opcional, y va ANTES del detallado. «Diseñar todo» diseña el pórtico — columnas y vigas; esto diseña las losas y tabiques que soporta, y las zapatas si las pedís. Un edificio sólo de pórticos puede saltearlo.', + // ════════════════════════════════════════════════════════════════════════ + // H1 · design.floor.state.* — estados honestos de las familias de piso + // + // Bloque contiguo a propósito. Las claves de un mismo namespace insertadas + // de forma dispersa son lo que produjo 64 y después 15 duplicados al + // fusionar ramas: dos inserciones independientes en los mismos lugares que + // git acepta sin marcar conflicto. Un bloque se ve como una inserción. + // + // M1 necesitará tocar estos diccionarios para `conn.gap.aluminium.scope`. + // Insertar ese cambio como OTRO bloque encabezado, no intercalado acá. + // ════════════════════════════════════════════════════════════════════════ + 'design.floor.state.error': 'Error en la pasada', + 'design.floor.state.notRun': 'Sin ejecutar', + 'design.floor.state.noElements': 'Sin elementos', + 'design.floor.state.skipped': 'Omitido', + 'design.floor.state.designed': 'Diseñado', + 'design.floor.state.refused': 'Rechazado', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'La pasada de pisos falló. Cualquier número que quede en pantalla es de una corrida anterior y no describe el modelo actual.', + 'design.floor.state.why.notRun': 'Todavía no se corrió el diseño de pisos, así que no hay nada clasificado. No es que no haya elementos: es que no se los miró.', + 'design.floor.state.why.noElements': 'El modelo no tiene elementos de esta familia. Es un hecho del modelo y se sabe sin correr nada.', + 'design.floor.state.why.skipped': 'La pasada clasificó estos elementos y no los diseñó ni los rechazó: quedaron fuera de su alcance.', + 'design.floor.state.why.designed': 'Diseñado con resultado completo: armaduras, verificación de corte y cláusulas citadas.', + 'design.floor.state.why.refused': 'La pasada se negó a diseñar. Cada rechazo nombra su elemento y la condición que lo detuvo.', + 'design.floor.state.why.provisional': 'Diseñó, y algo quedó incompleto: madurez sin validar o condiciones que el propio diseño no cubre. No es una verificación.', + 'design.floor.state.countUnavailable': 'Sin dato', + 'design.floor.state.countUnavailableWhy': 'No se muestra un número porque no hay uno: mostrar 0 diría que la pasada contó y no encontró nada.', + 'design.floor.state.scopeTitle': 'Alcance de la última corrida', + 'design.floor.state.scopeNone': 'Todavía no hubo corrida de pisos.', + 'design.floor.state.scope': 'Clasificó {classified} · diseñó {designed} · rechazó {refused} · omitió {skipped}', + 'design.floor.state.offFamilyTitle': 'Clasificados fuera de losa y tabique', + 'design.floor.state.offFamily': '{inclined} inclinados · {degenerate} degenerados', + 'design.floor.state.offFamilyWhy': 'Cáscaras que la pasada clasificó y que no son losa ni tabique: una rampa o una losa en pendiente cae en «inclinado», y una geometría que el clasificador no pudo resolver cae en «degenerado». Ninguna se diseña, y ninguna se descarta en silencio.', + 'design.floor.state.nextTitle': 'Qué hacer ahora', + 'design.floor.state.next.error': 'Revisá el mensaje del error y volvé a correr el diseño de pisos.', + 'design.floor.state.next.notRun': 'Corré «Diseñar y detallar pisos» para clasificar y diseñar estas familias.', + 'design.floor.state.next.noElements': 'No hay nada que hacer para esta familia en este modelo.', + 'design.floor.state.next.skipped': 'Revisá por qué quedaron fuera del alcance antes de emitir documentos.', + 'design.floor.state.next.designed': 'Corré el detallado coordinado para que estas armaduras entren en los documentos.', + 'design.floor.state.next.refused': 'Leé cada condición rechazada: son las que hay que resolver en el modelo.', + 'design.floor.state.next.provisional': 'Revisá las condiciones no cubiertas antes de tratar este diseño como definitivo.', + 'design.floor.state.scopeVsAll': '«Diseñar todo» diseña el pórtico —columnas y vigas— y no toca cáscaras ni cimientos. «Diseñar y detallar pisos» hace lo contrario: cáscaras y cimientos, y deja el pórtico intacto. Son dos pasadas distintas sobre familias distintas, no dos alcances de la misma.', 'design.memo.flexure': 'Flexión', 'design.memo.shear': 'Corte', diff --git a/web/src/lib/i18n/locales/fr.ts b/web/src/lib/i18n/locales/fr.ts index 938383e2f..571d3e990 100644 --- a/web/src/lib/i18n/locales/fr.ts +++ b/web/src/lib/i18n/locales/fr.ts @@ -3428,6 +3428,41 @@ const fr: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/hi.ts b/web/src/lib/i18n/locales/hi.ts index d1d56ae89..a07305cac 100644 --- a/web/src/lib/i18n/locales/hi.ts +++ b/web/src/lib/i18n/locales/hi.ts @@ -3427,6 +3427,41 @@ const hi: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/id.ts b/web/src/lib/i18n/locales/id.ts index 0cb26caa4..fb2344dda 100644 --- a/web/src/lib/i18n/locales/id.ts +++ b/web/src/lib/i18n/locales/id.ts @@ -3417,6 +3417,41 @@ const id: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/it.ts b/web/src/lib/i18n/locales/it.ts index 92fc48d68..516c5f2cc 100644 --- a/web/src/lib/i18n/locales/it.ts +++ b/web/src/lib/i18n/locales/it.ts @@ -3428,6 +3428,41 @@ const it: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/ja.ts b/web/src/lib/i18n/locales/ja.ts index 1bc2b5022..56562fd12 100644 --- a/web/src/lib/i18n/locales/ja.ts +++ b/web/src/lib/i18n/locales/ja.ts @@ -3407,6 +3407,41 @@ const ja: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/ko.ts b/web/src/lib/i18n/locales/ko.ts index 2a70b6ab3..d0a29c7ba 100644 --- a/web/src/lib/i18n/locales/ko.ts +++ b/web/src/lib/i18n/locales/ko.ts @@ -3414,6 +3414,41 @@ const ko: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/pt.ts b/web/src/lib/i18n/locales/pt.ts index 35395b048..657329d53 100644 --- a/web/src/lib/i18n/locales/pt.ts +++ b/web/src/lib/i18n/locales/pt.ts @@ -4888,6 +4888,48 @@ const pt: Translations = { 'detailing.floorRun.next': 'Execute o detalhamento coordenado, que coordena as barras que existirem então. A vista 3D e os documentos são projeções desse resultado.', 'detailing.floorRun.runningNote': 'A processar todo o edifício. Esta passagem não pode ser interrompida.', 'detailing.floorRun.whenToRun': 'Opcional, e roda ANTES do detalhamento. «Dimensionar tudo» dimensiona o pórtico — pilares e vigas; isto dimensiona as lajes e paredes que ele suporta, e as sapatas se você as pedir. Um edifício só de pórticos pode pulá-lo.', + // ════════════════════════════════════════════════════════════════════════ + // H1 · design.floor.state.* — estados honestos das famílias de piso + // + // Bloco contíguo de propósito. Chaves de um mesmo namespace inseridas de + // forma dispersa são o que produziu 64 e depois 15 duplicatas ao fundir + // ramos: duas inserções independentes nos mesmos lugares, que o git aceita + // sem sinalizar conflito. Um bloco se lê como uma inserção. + // + // M1 precisará destes dicionários para `conn.gap.aluminium.scope`. Inserir + // essa mudança como OUTRO bloco com cabeçalho, não intercalada aqui. + // ════════════════════════════════════════════════════════════════════════ + 'design.floor.state.error': 'Erro na passagem', + 'design.floor.state.notRun': 'Não executado', + 'design.floor.state.noElements': 'Sem elementos', + 'design.floor.state.skipped': 'Omitido', + 'design.floor.state.designed': 'Dimensionado', + 'design.floor.state.refused': 'Recusado', + 'design.floor.state.provisional': 'Provisório', + 'design.floor.state.why.error': 'A passagem de pisos falhou. Qualquer número ainda na tela é de uma execução anterior e não descreve este modelo.', + 'design.floor.state.why.notRun': 'O dimensionamento de pisos não foi executado, portanto nada está classificado. Não é que não existam elementos: é que ninguém olhou para eles.', + 'design.floor.state.why.noElements': 'O modelo não tem elementos desta família. É um fato do modelo, conhecido sem executar nada.', + 'design.floor.state.why.skipped': 'A passagem classificou estes e não os dimensionou nem os recusou: ficaram fora do seu escopo.', + 'design.floor.state.why.designed': 'Dimensionado com resultado completo: armaduras, verificação de cisalhamento e cláusulas citadas.', + 'design.floor.state.why.refused': 'A passagem recusou dimensionar. Cada recusa nomeia seu elemento e a condição que a impediu.', + 'design.floor.state.why.provisional': 'Dimensionou, e algo ficou incompleto: maturidade não validada, ou condições que o próprio dimensionamento não cobre. Não é uma verificação.', + 'design.floor.state.countUnavailable': 'Sem dado', + 'design.floor.state.countUnavailableWhy': 'Nenhum número é exibido porque não existe: um 0 diria que a passagem contou e não encontrou nada.', + 'design.floor.state.scopeTitle': 'Escopo da última execução', + 'design.floor.state.scopeNone': 'Ainda não houve execução de pisos.', + 'design.floor.state.scope': 'Classificou {classified} · dimensionou {designed} · recusou {refused} · omitiu {skipped}', + 'design.floor.state.offFamilyTitle': 'Classificados como nem laje nem parede', + 'design.floor.state.offFamily': '{inclined} inclinados · {degenerate} degenerados', + 'design.floor.state.offFamilyWhy': 'Cascas que a passagem classificou e que não são laje nem parede: uma rampa ou uma laje inclinada cai em «inclinado», e uma geometria que o classificador não conseguiu resolver cai em «degenerado». Nenhuma é dimensionada, e nenhuma é descartada em silêncio.', + 'design.floor.state.nextTitle': 'O que fazer agora', + 'design.floor.state.next.error': 'Leia a mensagem de erro e execute o dimensionamento de pisos novamente.', + 'design.floor.state.next.notRun': 'Execute «Dimensionar e detalhar pisos» para classificar e dimensionar estas famílias.', + 'design.floor.state.next.noElements': 'Não há nada a fazer para esta família neste modelo.', + 'design.floor.state.next.skipped': 'Descubra por que ficaram fora do escopo antes de emitir documentos.', + 'design.floor.state.next.designed': 'Execute o detalhamento coordenado para que estas armaduras cheguem aos documentos.', + 'design.floor.state.next.refused': 'Leia cada condição recusada: são essas que precisam ser resolvidas no modelo.', + 'design.floor.state.next.provisional': 'Revise as condições não cobertas antes de tratar este dimensionamento como definitivo.', + 'design.floor.state.scopeVsAll': '«Dimensionar tudo» dimensiona o pórtico — pilares e vigas — e não toca nenhuma casca nem sapata. «Dimensionar e detalhar pisos» faz o oposto: cascas e sapatas, deixando o pórtico intacto. São duas passagens sobre famílias diferentes, não dois escopos da mesma.', 'design.memo.flexure': 'Flexão', 'design.memo.shear': 'Cortante', diff --git a/web/src/lib/i18n/locales/ru.ts b/web/src/lib/i18n/locales/ru.ts index 2a94ff812..b140a41b7 100644 --- a/web/src/lib/i18n/locales/ru.ts +++ b/web/src/lib/i18n/locales/ru.ts @@ -3419,6 +3419,41 @@ const ru: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/tr.ts b/web/src/lib/i18n/locales/tr.ts index 223193bc7..95dc2f4aa 100644 --- a/web/src/lib/i18n/locales/tr.ts +++ b/web/src/lib/i18n/locales/tr.ts @@ -3427,6 +3427,41 @@ const tr: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', diff --git a/web/src/lib/i18n/locales/zh.ts b/web/src/lib/i18n/locales/zh.ts index 2d9308cd2..2816b813e 100644 --- a/web/src/lib/i18n/locales/zh.ts +++ b/web/src/lib/i18n/locales/zh.ts @@ -3416,6 +3416,41 @@ const zh: Translations = { 'design.stage.demands': 'Demands', 'design.stage.design': 'Design', 'design.stage.detailing': 'Detailing', + // ── H1 · design.floor.state.* ────────────────────────────────────────── + // English text: this locale is not in OFFERED_LOCALES, so it renders English + // anyway. The KEYS must exist so `locale-parity` cannot let the feature go + // missing in silence — the same convention PR20 used for `design.stage.*`. + 'design.floor.state.error': 'Pass failed', + 'design.floor.state.notRun': 'Not run', + 'design.floor.state.noElements': 'No elements', + 'design.floor.state.skipped': 'Skipped', + 'design.floor.state.designed': 'Designed', + 'design.floor.state.refused': 'Refused', + 'design.floor.state.provisional': 'Provisional', + 'design.floor.state.why.error': 'The floor pass failed. Any figure still on screen belongs to an earlier run and does not describe this model.', + 'design.floor.state.why.notRun': 'The floor design has not run, so nothing is classified. Not that there are no elements — that nobody has looked at them.', + 'design.floor.state.why.noElements': 'The model has no elements of this family. That is a fact about the model, known without running anything.', + 'design.floor.state.why.skipped': 'The pass classified these and neither designed nor refused them: they fell outside its scope.', + 'design.floor.state.why.designed': 'Designed with a complete result: reinforcement, shear check and cited clauses.', + 'design.floor.state.why.refused': 'The pass refused to design. Each refusal names its element and the condition that stopped it.', + 'design.floor.state.why.provisional': 'It designed, and something is incomplete: unvalidated maturity, or conditions the design itself does not cover. It is not a verification.', + 'design.floor.state.countUnavailable': 'No figure', + 'design.floor.state.countUnavailableWhy': 'No number is shown because there is none: a 0 would say the pass counted and found nothing.', + 'design.floor.state.scopeTitle': 'Scope of the last run', + 'design.floor.state.scopeNone': 'No floor run yet.', + 'design.floor.state.scope': 'Classified {classified} · designed {designed} · refused {refused} · skipped {skipped}', + 'design.floor.state.offFamilyTitle': 'Classified as neither slab nor wall', + 'design.floor.state.offFamily': '{inclined} inclined · {degenerate} degenerate', + 'design.floor.state.offFamilyWhy': 'Shells the pass classified that are neither slab nor wall: a ramp or a pitched slab lands in "inclined", and geometry the classifier could not resolve lands in "degenerate". Neither is designed, and neither is dropped in silence.', + 'design.floor.state.nextTitle': 'What to do now', + 'design.floor.state.next.error': 'Read the error message and run the floor design again.', + 'design.floor.state.next.notRun': 'Run "Design and detail floors" to classify and design these families.', + 'design.floor.state.next.noElements': 'There is nothing to do for this family in this model.', + 'design.floor.state.next.skipped': 'Find out why they fell outside the scope before issuing documents.', + 'design.floor.state.next.designed': 'Run the coordinated detailing so this reinforcement reaches the documents.', + 'design.floor.state.next.refused': 'Read each refused condition: those are the ones to resolve in the model.', + 'design.floor.state.next.provisional': 'Review the uncovered conditions before treating this design as final.', + 'design.floor.state.scopeVsAll': '"Design all" designs the frame — columns and beams — and touches no shell and no footing. "Design and detail floors" does the opposite: shells and footings, leaving the frame untouched. They are two passes over different families, not two scopes of one pass.', 'design.stage.documents': 'Documents', 'design.stage.model': 'Model', 'design.stage.needDemands': 'Compute demands first: the checks read them per station.', From 23ce3e345003630df51e97cda4fb2c538b70a7b4 Mon Sep 17 00:00:00 2001 From: Bauti Date: Thu, 20 Aug 2026 21:17:18 -0300 Subject: [PATCH 02/36] fix(design): timber C24 was entering the concrete pipeline as 24 MPa concrete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `materialFamilyOf` classifies by a DECLARED grade when it has both a `gradeId` and a lookup — `if (material.gradeId && lookupGrade)` — and otherwise infers from the magnitude of `fy`, reading `fy <= 80 MPa` as concrete. The gradeId always arrived. `ContextModelData.materials` is handed the live `modelStore.materials`, so real `Material` objects flow through and PR #132's field was there the whole time. What never arrived was the lookup: no production call site supplied one, so the declared branch could not run and every material in the app was classified by a number. Timber C24 has a characteristic bending strength of 24 MPa. It was classified as concrete and admitted to the reinforced-concrete design pipeline, where 24 MPa reads as an ordinary f'c. `en338-c24` has been in `non-metal-grades.ts` with family `timber` since #132 landed, and nothing consulted it. ── The fix is one argument ──────────────────────────────────────── `design-run.svelte.ts` is the only production caller of `buildAllMemberContexts`, and it now passes `lookupGrade: catalogueGradeFamily`. That lookup is M1's, taken as-is rather than reimplemented: two functions answering "what family is this grade" would eventually be two answers, and this one is already what the metallic surface uses. Only the module came across — `engine/steel/grade-family.ts`, 86 lines, importing nothing M1 owns — not M1's branch. Its contract is `(gradeId) => StructuralMaterialFamily | null`, where null means "this catalogue cannot answer" and the caller falls back. `ContextModelData.materials` now declares `gradeId?: string`. Not a blocker — every call site casts `as never`, so it arrived regardless — but the type said the declared path could not work when it could, which is why this looked like a typing defect and was a wiring one. ── This moves members between pipelines, in both directions ──────── `buildAllMemberContexts` keeps only `materialFamily === 'concrete'`, so classification is the boundary between the concrete pipeline and the metallic inventory. Declaration replacing inference means: fy 30, declares steel → LEAVES the concrete side (was: admitted) fy 100, declares concrete → ENTERS the concrete side (was: excluded) timber C24 → neither pipeline (was: concrete) aluminium, any fy → aluminium, not "steel" (was: steel by fy) Aluminium was already excluded from concrete, and for the wrong reason: the magnitude cannot tell it from steel, so the metallic surface filed it under steel. It is now read as aluminium. Nothing changed for a correctly classified element: `rc-baseline-digest` still carries `c6a055ef135d0a71`. No formula, no authority, no solver. ── Tests ────────────────────────────────────────────────────────── `declared-grade-classification.test.ts`, 17 cases against the REAL catalogue rather than an injected stub — `steel-excluded-from-rc.test.ts` already proves the mechanism with a hand-written lookup; what was missing is that the shipped tables give the right answers and that production uses them. Concrete, timber, steel and aluminium by declaration; absent and withdrawn gradeIds falling back; the pipeline admitting concrete and refusing the other three at the SAME `fy`; and the defect itself pinned — remove the lookup and timber walks back in. Plus the H1/M1 boundary as tests, so neither branch meets the two crossings by surprise. ── One conflict with M1, reported rather than papered over ───────── M1's `grade-family.test.ts` came with the module and 10 of its 12 cases pass here. The other two assert `emptyReason: 'nonFerrousOnly'` and a `steel.notice.nonFerrousNotCovered` notice, both from M1's later changes to `steel-inventory.ts` and `locales/steel/*` — files H1 must not touch. So that test stays in M1. Editing it here would create a divergent copy of a file M1 owns, which is the same defect as a duplicated i18n key: two independent versions that git merges without flagging. H1 covers the contract it depends on in its own file instead. Gates: unit 371 files / 6943 tests · build tests 14 · production build 14.9 s · typecheck 479 against baseline 479 with no new errors · rc-baseline-digest 5/5 · rc-untouched-by-steel 4/4 · generated-models-solve 13/13. --- .../declared-grade-classification.test.ts | 183 ++++++++++++++++++ web/src/lib/engine/design/member-context.ts | 11 +- web/src/lib/engine/steel/grade-family.ts | 86 ++++++++ web/src/lib/store/design-run.svelte.ts | 33 ++++ 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/engine/design/__tests__/declared-grade-classification.test.ts create mode 100644 web/src/lib/engine/steel/grade-family.ts diff --git a/web/src/lib/engine/design/__tests__/declared-grade-classification.test.ts b/web/src/lib/engine/design/__tests__/declared-grade-classification.test.ts new file mode 100644 index 000000000..67c8c8da0 --- /dev/null +++ b/web/src/lib/engine/design/__tests__/declared-grade-classification.test.ts @@ -0,0 +1,183 @@ +/** + * Timber C24 is not 24 MPa concrete. + * + * ── The defect ───────────────────────────────────────────────────── + * + * `materialFamilyOf` classifies by a declared grade when it has one and a lookup to resolve + * it — `if (material.gradeId && lookupGrade)` — and otherwise infers from the MAGNITUDE of + * `fy`, reading `fy <= 80 MPa` as concrete. + * + * The gradeId always arrived: `ContextModelData.materials` is handed the live + * `modelStore.materials`, so real `Material` objects flow through. What never arrived was the + * lookup. No production call site supplied one, so the declared branch could not run and every + * material in the app was classified by magnitude. + * + * Timber C24 has a characteristic bending strength of 24 MPa. It was classified as concrete + * and admitted to the reinforced-concrete design pipeline, where 24 MPa reads as an ordinary + * f'c. The grade was in the catalogue the whole time — `en338-c24`, family `timber` — and + * nothing consulted it. + * + * ── What these assert ────────────────────────────────────────────── + * + * Against the REAL catalogue, not an injected stub. `steel-excluded-from-rc.test.ts` already + * covers the override with a hand-written lookup, which proves the mechanism; what was missing + * is that the shipped catalogue gives the right answers and that the production wiring uses it. + */ + +import { describe, it, expect } from 'vitest'; +import { materialFamilyOf } from '../../steel/material-family'; +import { catalogueGradeFamily } from '../../steel/grade-family'; +import { buildAllMemberContexts, type ContextModelData } from '../member-context'; + +/** Real ids from the shipped catalogues, not invented ones. */ +const GRADES = { + concreteAr: 'cirsoc-h25', // CIRSOC 201, f'c 25 + concreteUs: 'aci-3000', + timberC24: 'en338-c24', // EN 338 C24 — 24 MPa, the case that started this + steelAr: 'iram-f24', // IRAM F-24 — fy 240 + aluminium: 'alu-5052-h32', // EN AW-5052 — fy 195 +} as const; + +const family = (gradeId: string, fy: number) => + materialFamilyOf({ gradeId, fy }, catalogueGradeFamily); + +describe('the shipped catalogue answers each family correctly', () => { + it('declared concrete is concrete', () => { + for (const id of [GRADES.concreteAr, GRADES.concreteUs]) { + const v = family(id, 25); + expect(v.family, id).toBe('concrete'); + expect(v.basis, id).toBe('declaredGrade'); + } + }); + + it('declared timber is timber, at a strength the inference would call concrete', () => { + // 24 MPa is below the 80 MPa ceiling, so the inference says concrete. The declaration + // must win — that is the entire point of PR #132's field. + const v = family(GRADES.timberC24, 24); + expect(v.family).toBe('timber'); + expect(v.basis).toBe('declaredGrade'); + }); + + it('declared steel is steel', () => { + const v = family(GRADES.steelAr, 240); + expect(v.family).toBe('steel'); + expect(v.basis).toBe('declaredGrade'); + }); + + it('declared aluminium is aluminium, and not merely "metal"', () => { + // The inference cannot tell aluminium from steel: both are above the fy ceiling and it + // reports steel for either. So this one was already excluded from concrete, and excluded + // for the wrong reason — which matters, because the metallic surface lists it by family. + const v = family(GRADES.aluminium, 195); + expect(v.family).toBe('aluminium'); + expect(v.basis).toBe('declaredGrade'); + }); +}); + +describe('the documented fallback survives', () => { + it('no gradeId keeps the magnitude inference', () => { + const v = materialFamilyOf({ fy: 25 }, catalogueGradeFamily); + expect(v.family).toBe('concrete'); + // Not a declaration — and the verdict says so, which is what lets a surface warn about it. + expect(v.basis).not.toBe('declaredGrade'); + }); + + it('an unknown gradeId falls back rather than reporting unknown', () => { + // A stored project can name a grade that has since been withdrawn. Falling back is better + // than calling a material with a plain strength unclassifiable. + const v = materialFamilyOf({ gradeId: 'withdrawn-in-2019', fy: 30 }, catalogueGradeFamily); + expect(v.family).toBe('concrete'); + expect(v.basis).not.toBe('declaredGrade'); + }); + + it('and the lookup itself returns null for an id it cannot answer', () => { + // The contract: null means "this catalogue cannot answer", not "unknown family". + expect(catalogueGradeFamily('withdrawn-in-2019')).toBeNull(); + expect(catalogueGradeFamily(GRADES.timberC24)).toBe('timber'); + }); +}); + +/** + * The pipeline boundary. + * + * `buildAllMemberContexts` keeps only `materialFamily === 'concrete'`, so this is where a + * misclassification becomes a design. One member per family, all with a low `fy` so that the + * inference would admit every one of them. + */ +describe('the concrete pipeline admits concrete and nothing else', () => { + function model(gradeId: string, fy: number): ContextModelData { + return { + nodes: new Map([ + [1, { id: 1, x: 0, y: 0, z: 0 }], + [2, { id: 2, x: 5, y: 0, z: 0 }], + ]), + elements: new Map([ + [1, { id: 1, nodeI: 1, nodeJ: 2, sectionId: 1, materialId: 1, type: 'frame' }], + ]), + sections: new Map([[1, { id: 1, name: 'V 20x40', b: 0.2, h: 0.4 }]]), + materials: new Map([[1, { id: 1, name: 'M', fy, gradeId }]]), + supports: new Map([[1, { nodeId: 1, type: 'fixed' }]]), + }; + } + + const admitted = (gradeId: string, fy: number) => + [...buildAllMemberContexts(model(gradeId, fy), { lookupGrade: catalogueGradeFamily }).keys()]; + + it('admits a declared concrete member', () => { + expect(admitted(GRADES.concreteAr, 25)).toEqual([1]); + }); + + it('refuses timber C24 — the case this was written for', () => { + // Same fy as the concrete above. Only the declaration differs. + expect(admitted(GRADES.timberC24, 24)).toEqual([]); + }); + + it('refuses a declared steel member even at a concrete-looking strength', () => { + expect(admitted(GRADES.steelAr, 30)).toEqual([]); + }); + + it('refuses a declared aluminium member even at a concrete-looking strength', () => { + expect(admitted(GRADES.aluminium, 30)).toEqual([]); + }); + + it('without the lookup, every one of them is admitted — the defect, pinned', () => { + // The state before this change, kept as a test so the regression is visible rather than + // remembered. Remove the lookup and timber walks into the concrete pipeline. + const noLookup = (gradeId: string, fy: number) => + [...buildAllMemberContexts(model(gradeId, fy), {}).keys()]; + expect(noLookup(GRADES.timberC24, 24)).toEqual([1]); + expect(noLookup(GRADES.concreteAr, 25)).toEqual([1]); + }); +}); + +/** + * The H1/M1 boundary. + * + * Classification moves from inference to declaration, so members can change pipeline in BOTH + * directions. These are the two crossings, stated as tests so neither branch discovers them by + * surprise. + */ +describe('the boundary between the concrete pipeline and the metallic inventory', () => { + it('a low-fy member declaring steel LEAVES the concrete side', () => { + // Inference: concrete (fy 30 ≤ 80). Declaration: steel. It leaves. + expect(materialFamilyOf({ gradeId: GRADES.steelAr, fy: 30 }, catalogueGradeFamily).family) + .toBe('steel'); + expect(materialFamilyOf({ fy: 30 }, catalogueGradeFamily).family).toBe('concrete'); + }); + + it('a high-fy member declaring concrete ENTERS the concrete side', () => { + // Inference: steel (fy 100 > 80). Declaration: concrete. It enters. + expect(materialFamilyOf({ gradeId: GRADES.concreteAr, fy: 100 }, catalogueGradeFamily).family) + .toBe('concrete'); + expect(materialFamilyOf({ fy: 100 }, catalogueGradeFamily).family).toBe('steel'); + }); + + it('timber and masonry belong to NEITHER pipeline', () => { + // Not a concrete member and not a metallic one. The metallic inventory filters on + // `isSteel`, so timber does not appear there either — it is simply not designed, which is + // the honest outcome for a material this app has no authority for. + const v = materialFamilyOf({ gradeId: GRADES.timberC24, fy: 24 }, catalogueGradeFamily); + expect(v.family).toBe('timber'); + expect(['concrete', 'steel']).not.toContain(v.family); + }); +}); diff --git a/web/src/lib/engine/design/member-context.ts b/web/src/lib/engine/design/member-context.ts index de9577647..219071fa3 100644 --- a/web/src/lib/engine/design/member-context.ts +++ b/web/src/lib/engine/design/member-context.ts @@ -60,7 +60,16 @@ export interface ContextModelData { nodes: Map; elements: Map; sections: Map; - materials: Map; + /** + * `gradeId` is PR #132's declared grade, and it is what decides the material FAMILY. + * + * Declared here rather than left to the `as never` casts every call site uses. The field was + * always present at runtime — callers pass the live `modelStore.materials`, so real + * `Material` objects flow through — but the type said otherwise, which made it look as + * though the declared-grade path could not work. It could; nothing was supplying the + * lookup. Naming it makes the contract match what actually arrives. + */ + materials: Map; supports: Map; } diff --git a/web/src/lib/engine/steel/grade-family.ts b/web/src/lib/engine/steel/grade-family.ts new file mode 100644 index 000000000..24c563403 --- /dev/null +++ b/web/src/lib/engine/steel/grade-family.ts @@ -0,0 +1,86 @@ +/** + * The catalogue side of `materialFamilyOf`: a declared grade, resolved to a family. + * + * ── Why this file exists at all ──────────────────────────────────── + * + * `material-family.ts` deliberately does not import a catalogue. It takes a + * `GradeFamilyLookup` so it stays pure and testable, and PR21 left every call site passing + * `undefined` with a comment saying the grade catalogue "is not on this branch". It is: the + * merge that brought `structural-grades.ts` and `non-metal-grades.ts` in + * (`d1ba4fb2`, PR #132) is an ancestor of this branch's base. So the lookup can be supplied, + * and every family verdict that used to be a guess about the magnitude of `fy` becomes a + * reading of what the project recorded. + * + * The injection point stays where it was. This module is the implementation, not a + * replacement of the seam: `materialFamilyOf` still works with no catalogue at all, which is + * what keeps its tests free of one. + * + * ── Why the non-metals are in here too ──────────────────────────── + * + * `material-presets.ts` writes `gradeId` for concrete and timber as well — `cirsoc-h25`, + * `en338-c24` — because they come out of the same picker. A lookup that only knew the metals + * would return null for those and fall back to the `fy <= 80` inference, which happens to + * get concrete right and would get a 60 MPa timber class wrong in a way nobody would notice. + * Answering from the catalogue for every family it has is both easier and honest. + * + * ── What it will not do ─────────────────────────────────────────── + * + * It never guesses. An id the catalogue does not know returns null, which sends + * `materialFamilyOf` back to the inference — the right answer for a project saved against a + * grade that has since been withdrawn, and the reason the inference is kept rather than + * deleted. + * + * Pure: no store, no runes, no i18n. + */ + +import { gradeById, type GradeFamily } from '../../data/structural-grades'; +import { CONCRETE, TIMBER } from '../../data/non-metal-grades'; +import type { GradeFamilyLookup, StructuralMaterialFamily } from './material-family'; + +/** + * A metal grade's family, as the product-standard catalogue names it, mapped onto the + * families the product distinguishes. + * + * Stainless resolves to `steel` because it is one: ferrous, same modulus order, and the + * distinction that matters downstream is metal-versus-concrete, not the alloy. That is not a + * claim that a stainless member can be checked to CIRSOC 301 — nothing metallic can be + * checked to anything here — it is a statement about what the material is. + * + * Written as an exhaustive switch rather than a record so that a new `GradeFamily` in the + * catalogue fails to compile here instead of silently resolving to `unknown`. + */ +function familyOfMetalGrade(family: GradeFamily): StructuralMaterialFamily { + switch (family) { + case 'hot-rolled': + case 'cold-formed': + case 'stainless': + return 'steel'; + case 'aluminium': + return 'aluminium'; + } +} + +/** + * Non-metal ids, indexed once. + * + * Both arrays are module-level constants, so this map is built once per session and cannot + * drift from them. `concrete` and `timber` are the `family` fields of those very rows, read + * rather than restated. + */ +const NON_METAL: Map = new Map([ + ...CONCRETE.map((c) => [c.id, c.family] as const), + ...TIMBER.map((w) => [w.id, w.family] as const), +]); + +/** + * The lookup to hand `materialFamilyOf`. + * + * Null for an unknown id, which is the contract: not "unknown family", but "this catalogue + * cannot answer", so the caller falls back rather than reporting a material with a plain + * strength as unclassifiable. + */ +export const catalogueGradeFamily: GradeFamilyLookup = (gradeId) => { + const metal = gradeById(gradeId); + if (metal) return familyOfMetalGrade(metal.family); + return NON_METAL.get(gradeId) ?? null; +}; diff --git a/web/src/lib/store/design-run.svelte.ts b/web/src/lib/store/design-run.svelte.ts index b72eaa185..fbd2c3d8a 100644 --- a/web/src/lib/store/design-run.svelte.ts +++ b/web/src/lib/store/design-run.svelte.ts @@ -25,6 +25,7 @@ import { censusRcCheckability } from '../engine/auto-verify'; import { buildAllMemberContexts, buildCriticalSectionMap, type ContextModelData, type MemberContext, } from '../engine/design/member-context'; +import { catalogueGradeFamily } from '../engine/steel/grade-family'; import { runOrientationDiagnostic } from '../engine/design/orientation-diagnostic'; import { runDesign, designMember, DEFAULT_RUN_MS } from '../engine/design/candidate-search'; import { getDesignCode, type DesignCodeId } from '../engine/design/code-adapter'; @@ -125,6 +126,38 @@ function createDesignRunStore() { codeEdition: concreteEdition(), concrete: modelStore.model.codeSettings?.concrete, solveGeneration: verificationStore.solveGeneration, + /* + * A DECLARED grade decides the material family. An inferred one is the fallback. + * + * ── The defect this closes ────────────────────────────────── + * + * `materialFamilyOf` needs both a `gradeId` and a lookup: `if (material.gradeId && + * lookupGrade)`. The gradeId has always arrived — `md.materials` is the live map, so + * the real `Material` objects flow through — but nothing in production ever supplied + * the lookup. So the declared branch never ran and every material was classified by + * the MAGNITUDE of `fy`, with `fy <= 80 MPa` read as concrete. + * + * Timber C24 is 24 MPa. It was being classified as concrete and admitted to the + * reinforced-concrete pipeline, where 24 MPa reads as an ordinary f'c. The grade is in + * the catalogue (`non-metal-grades.ts`, `en338-c24`, family `timber`) and was simply + * never consulted. Aluminium below the threshold had the same problem. + * + * ── What this changes, and it is not only timber ───────────── + * + * Classification moves from inference to declaration, so members can CHANGE PIPELINE + * in both directions: one that declares a steel grade and has a low `fy` leaves the + * concrete design set, and one whose stored grade the catalogue no longer knows falls + * back to the inference exactly as before. `buildAllMemberContexts` keeps only + * `materialFamily === 'concrete'`, so this is the boundary between the concrete + * pipeline and the metallic inventory — see the note in `grade-family.ts`. + * + * The lookup itself is M1's `catalogueGradeFamily`, taken as-is rather than + * reimplemented: two functions answering "what family is this grade" would be two + * answers, and this one is already the one the metallic surface uses. Its contract is + * `(gradeId) => StructuralMaterialFamily | null`, where null means "this catalogue + * cannot answer" and the caller falls back. + */ + lookupGrade: catalogueGradeFamily, }); verificationStore.setDemandData(contexts, orient.issues); resultsStore.diagramType = 'verification'; From 234c9b9405ea34886583b6fee7b59085bc00c76b Mon Sep 17 00:00:00 2001 From: Bauti Date: Thu, 20 Aug 2026 22:04:19 -0300 Subject: [PATCH 03/36] style(design): the sheet's control group joins the token system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR20's handoff left this open in its own words: "The `
Sheet
` keeps a native legend border, which is the one control group in the panel that does not match the others." ── What it actually was ─────────────────────────────────────────── `DetailingWorkflow` styled it as `border: 1px solid rgba(143, 163, 179, 0.35)` with a `legend` carrying no `color` at all, so the legend inherited. Meanwhile `ProReportDialog` and `ProAutoLoadsDialog` both use `1px solid var(--st-surface-3)` with the legend in `var(--st-text-2)`. Three fieldsets, one convention, and this was the one outside it. The border was not an arbitrary colour either. `--st-hair` is `rgba(143, 163, 179, 0.22)` and `--st-hair-strong` is `0.38` — the file was writing the hair token out by hand at a third alpha. All nine of its raw colours were that: eight slate values at 0.14 / 0.18 / 0.2 / 0.25 / 0.3 / 0.35, plus a green and a blue of its own. PR20's regulations pass found the same pattern and said so: "`rgba(143, 163, 179, …)` appeared four times, hardcoded beside the tokens that mean exactly that." `DetailingWorkflow` is now at zero raw colours. `.assemblies button.selected` uses `--st-selected-bg`, which exists and means exactly that — it had been a translucent slate, which reads as a hover rather than as a selection. ── The audit, and why this one first ────────────────────────────── The concrete design surface carries 132 raw colours across eighteen files. Sorted by what this branch may touch: concrete-only BatchEditDialog 3 · ConflictInspector 10 · DesignFamilyPanel 2 · FloorFamiliesPanel 1 · FootingCadHandoffPanel 3 · FootingMatPanel 3 · FootingMatPhysicalPanel 20 · ProvisionalBanner 4 · RebarScenePanel 13 · RebarStatusPanel 19 · SectionAdviceDialog 2 · SelectionDetails 9 · TorsionBanner 4 · VerificationDetail 3 shared PRO DesignToolbar 12 · OutcomeBadge 14 3-D viewer RebarWorkspace 6 · RebarViewport3D 4 `OutcomeBadge` is referenced by M1's `SteelStatusBadge`, and `DesignToolbar` is the PRO command row: both need coordination. The viewer is out of scope. So the named defect was also the one that could be done alone. Worth recording: `BatchEditDialog` — the file this pass was pointed at first — was ALREADY tokenised (`var(--st-surface-3)`). Its three raw colours are elsewhere in the file. The fieldset PR20 meant is the sheet's, in `DetailingWorkflow`. ── The debt is now measured, not remembered ─────────────────────── `concrete-design-raw-colours.test.ts` records a per-file ceiling that may fall and may never rise — the same shape as `scripts/typecheck-baseline.json`. A file absent from the map has a ceiling of zero, so a NEW component must use tokens from its first line, and `DetailingWorkflow` is pinned at zero so it cannot regress. `design-tokens-resolve.test.ts` could not catch any of this: it checks that every `--st-*` a component REFERENCES is defined, and is blind to a component that never references one. ── Measured in the browser, not asserted from source ─────────────── `detailing-sheet-fieldset.spec.ts` at 1280×720 compares the RESOLVED border against the resolved `--st-surface-3`, and the resolved legend colour against `--st-text-2` — plus a negative assertion against the old literal, which looks nearly identical on screen and would pass a screenshot. It also checks the legend is not the inherited body colour, and that the panel does not overflow sideways. Three corrections that pass went in while writing it, each worth the note: the sheet controls only exist once detailing has been generated, so the spec loads a model and runs `designAll` and is `@slow` for the same reason PR20's sheet tests are; `getByText(/detailing/)` picks up the ribbon's DISABLED "3-D detailing" command and waits for it forever, so the disclosure is reached by its testid; and the first overflow check walked every descendant and flagged 433 of them, which is not overflow but tables and wells doing what `overflow-x: auto` is for. It measures the container, as PR20's spec already did. One locale in the browser rather than three: that the legend key is translated in en/es/pt is already held by `locale-parity` and `pro-flow-coverage`, which read the dictionaries directly, and one `designAll` per locale is minutes of suite for a fact two unit gates already prove. Nothing outside the concrete surface was touched. The seven floor states and the gradeId classification are untouched. Gates: unit 372 files / 6949 tests · build tests 14 · production build 14.7 s · typecheck 479 against baseline 479 · raw-colour ceiling 6/6 · detailing-sheet-fieldset 6/6 at 1280×720. --- web/e2e/detailing-sheet-fieldset.spec.ts | 149 ++++++++++++++++++ .../pro/design/DetailingWorkflow.svelte | 29 ++-- .../concrete-design-raw-colours.test.ts | 141 +++++++++++++++++ 3 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 web/e2e/detailing-sheet-fieldset.spec.ts create mode 100644 web/src/lib/__tests__/concrete-design-raw-colours.test.ts diff --git a/web/e2e/detailing-sheet-fieldset.spec.ts b/web/e2e/detailing-sheet-fieldset.spec.ts new file mode 100644 index 000000000..3df141d14 --- /dev/null +++ b/web/e2e/detailing-sheet-fieldset.spec.ts @@ -0,0 +1,149 @@ +/** + * The sheet's control group looks like every other control group. + * + * ── The defect, as PR20's handoff recorded it ────────────────────── + * + * "The `
Sheet
` keeps a native legend border, which is the one control + * group in the panel that does not match the others." + * + * Measured rather than described: it carried `border: 1px solid rgba(143, 163, 179, 0.35)` — + * `--st-hair-strong` (0.38) written out by hand — and its `legend` had no `color` at all, so + * it inherited whatever was around it. `ProReportDialog` and `ProAutoLoadsDialog` both use + * `1px solid var(--st-surface-3)` with the legend in `var(--st-text-2)`. + * + * So these assertions compare the RESOLVED colour against the resolved token, which is the + * only way to prove a component is on the system rather than near it. A hand-written + * approximation passes a screenshot and fails this. + */ + +import { test, expect, designAll, loadModel } from './fixtures'; +import type { Page } from '@playwright/test'; + +test.use({ viewport: { width: 1280, height: 720 } }); + +/** The computed value of a design token, as the browser resolves it. */ +const token = (page: Page, name: string) => + page.evaluate( + (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(), + name, + ); + +/** Resolve a colour string through the browser, so `rgba(...)` and a token compare equal. */ +const resolve = (page: Page, colour: string) => + page.evaluate((c) => { + const el = document.createElement('span'); + el.style.color = c; + document.body.appendChild(el); + const out = getComputedStyle(el).color; + el.remove(); + return out; + }, colour); + +/** + * The sheet controls exist only once there is detailing to draw. + * + * A fresh model shows `detailing-empty` and no `
` at all, so every assertion below + * needs the pipeline run first. That is why PR20's own sheet tests are `@slow` and call + * `designAll` — this follows them rather than inventing a shortcut. + */ +async function openDetailing(page: Page) { + // A model first: `designAll` solves and designs, and it polls `runCounts().total > 0`, which + // an empty model can never satisfy. + await loadModel(page, 'rc-design-qa-8'); + await designAll(page); + await page.getByTestId('pr-stage-design').click(); + await page.getByTestId('pr-cmd-design').click(); + // By its disclosure's own testid. A text match on /detailing/ picks up the ribbon's + // "3-D detailing" command, which is DISABLED on a fresh model, and waits for it forever. + const disclosure = page.getByTestId('detailing-disclosure'); + await expect(disclosure).toBeAttached(); + if (await disclosure.getAttribute('open') === null) { + // `.first()`: the stage body contains its own nested `
`, so the disclosure has + // two summaries and only the outer one opens the section. + await disclosure.locator('summary').first().click(); + } + await expect(page.getByTestId('detailing-workflow')).toBeVisible(); +} + +const fieldset = (page: Page) => + page.getByTestId('detailing-workflow').locator('.sheet-controls fieldset'); + +test.describe('@slow the sheet fieldset is on the token system', () => { + test('its border is the same colour as the other dialogs use', async ({ pro: page }) => { + await openDetailing(page); + const border = await fieldset(page).evaluate((el) => getComputedStyle(el).borderTopColor); + const expected = await resolve(page, await token(page, '--st-surface-3')); + expect(border).toBe(expected); + }); + + test('the legend has a colour of its own rather than inheriting', async ({ pro: page }) => { + await openDetailing(page); + const legend = fieldset(page).locator('legend'); + const colour = await legend.evaluate((el) => getComputedStyle(el).color); + const expected = await resolve(page, await token(page, '--st-text-2')); + expect(colour).toBe(expected); + // And it is NOT the body colour, which is what inheriting gave it. + const body = await page.evaluate(() => getComputedStyle(document.body).color); + expect(colour).not.toBe(body); + }); + + test('the border is not the hand-written slate it used to be', async ({ pro: page }) => { + await openDetailing(page); + const border = await fieldset(page).evaluate((el) => getComputedStyle(el).borderTopColor); + // The old literal, resolved. If someone reinstates it, this fails even though the two + // look nearly identical on screen. + const old = await resolve(page, 'rgba(143, 163, 179, 0.35)'); + expect(border).not.toBe(old); + }); +}); + +test.describe('@slow the panel still fits at 1280×720', () => { + test('the panel itself does not overflow sideways', async ({ pro: page }) => { + await openDetailing(page); + /* + * Measured on the CONTAINER, which is what `pro-design-workflow.spec.ts` already does. + * + * A first version of this walked every descendant and flagged 433 of them. That is not + * overflow: a table with `overflow-x: auto` and a scroll well both report + * `scrollWidth > clientWidth` by design, and that is what they are for. The defect is a + * panel wider than its own box, not a scroller doing its job. + */ + const box = await page.getByTestId('detailing-workflow') + .evaluate((el) => ({ scroll: el.scrollWidth, client: el.clientWidth })); + expect(box.scroll, 'the detailing panel does not overflow sideways') + .toBeLessThanOrEqual(box.client + 1); + }); + + test('the fieldset and its legend are both visible, not clipped', async ({ pro: page }) => { + await openDetailing(page); + await expect(fieldset(page)).toBeVisible(); + const box = await fieldset(page).boundingBox(); + expect(box!.width).toBeGreaterThan(40); + await expect(fieldset(page).locator('legend')).toBeVisible(); + }); +}); + +/* + * One locale in the browser, not three. + * + * That the legend key exists and is translated in en/es/pt is already proven by + * `locale-parity` and `pro-flow-coverage`, which read the dictionaries directly. What only a + * browser can measure is that the STYLE survives a different word length — and one run of + * `designAll` per locale is minutes of suite for a fact two unit gates already hold. + */ +for (const [locale, legend] of [ + ['es', /hoja|l.mina/i], +] as const) { + test.describe(`@slow the sheet group is legible in ${locale}`, () => { + test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } }); + + test('the legend is translated and still styled', async ({ pro: page }) => { + await openDetailing(page); + await expect(fieldset(page).locator('legend')).toHaveText(legend); + // The styling is not language-dependent, and a longer word must not break the border. + const colour = await fieldset(page).locator('legend') + .evaluate((el) => getComputedStyle(el).color); + expect(colour).toBe(await resolve(page, await token(page, '--st-text-2'))); + }); + }); +} diff --git a/web/src/components/pro/design/DetailingWorkflow.svelte b/web/src/components/pro/design/DetailingWorkflow.svelte index b2918203d..3289c22d9 100644 --- a/web/src/components/pro/design/DetailingWorkflow.svelte +++ b/web/src/components/pro/design/DetailingWorkflow.svelte @@ -380,20 +380,20 @@ .empty { opacity: 0.7; } ul { list-style: none; margin: 0; padding: 0; } .assemblies button { width: 100%; text-align: left; padding: 0.4rem 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; background: none; border: 1px solid transparent; border-radius: 4px; color: inherit; cursor: pointer; } - .assemblies button.selected { border-color: currentColor; background: rgba(143, 163, 179,0.14); } + .assemblies button.selected { border-color: currentColor; background: var(--st-selected-bg); } .assemblies button:focus-visible { outline: 2px solid currentColor; outline-offset: 1px; } .label { flex: 1; } header { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; } .badges { display: flex; gap: 0.35rem; flex-wrap: wrap; } .state, .maturity, .rev, .superseded { font-size: 0.7rem; font-weight: 600; padding: 0.1rem 0.4rem; border-radius: 3px; } - .state { background: rgba(143, 163, 179,0.25); } + .state { background: var(--st-surface-3); } .state-constructible, .state-reviewed, .state-issued { background: var(--st-surface-3); color: var(--st-text); } /* Provisional, stale and superseded are never green. */ .maturity { background: var(--st-surface-3); color: var(--st-text); } .superseded { background: var(--st-accent); color: var(--st-text); } .progress { list-style: none; display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.5rem 0; padding: 0; } - .progress li { font-size: 0.7rem; padding: 0.15rem 0.45rem; border-radius: 3px; background: rgba(143, 163, 179,0.18); opacity: 0.6; } - .progress li.done { opacity: 1; background: rgba(20,83,45,0.5); } + .progress li { font-size: 0.7rem; padding: 0.15rem 0.45rem; border-radius: 3px; background: var(--st-surface-3); opacity: 0.6; } + .progress li.done { opacity: 1; background: var(--st-green); } .progress li[aria-current='step'] { outline: 1px solid currentColor; } .notice { margin: 0.4rem 0; padding: 0.4rem 0.55rem; border-radius: 4px; line-height: 1.35; } .notice.warning { background: var(--st-surface-3); color: var(--st-text); } @@ -402,22 +402,31 @@ details.bars { margin: 0.5rem 0; } details.bars summary { cursor: pointer; font-size: 0.8rem; } ul.barlist { list-style: none; margin: 0.3rem 0 0; padding: 0; max-height: 16rem; overflow: auto; } - ul.barlist > li { display: flex; gap: 0.5rem; align-items: center; font-size: 0.76rem; padding: 0.15rem 0; border-top: 1px solid rgba(143, 163, 179,0.2); } - ul.barlist > li.locked { background: rgba(30, 69, 112, 0.35); } + ul.barlist > li { display: flex; gap: 0.5rem; align-items: center; font-size: 0.76rem; padding: 0.15rem 0; border-top: 1px solid var(--st-hair); } + ul.barlist > li.locked { background: var(--st-blue); } .bar-id { font-family: monospace; min-width: 7rem; } .bar-dia, .bar-len { min-width: 4rem; } .bar-role { flex: 1; opacity: 0.8; } .lock { font-size: 0.7rem; padding: 0.05rem 0.35rem; } .conflict-nav { display: flex; align-items: center; gap: 0.5rem; } .conflict-nav button { min-width: 1.8rem; } - fieldset { border: 1px solid rgba(143, 163, 179,0.35); border-radius: 4px; padding: 0.3rem 0.5rem; } - legend { font-size: 0.75rem; padding: 0 0.3rem; } + /* + The sheet's control group, on the same footing as every other one. + + `ProReportDialog` and `ProAutoLoadsDialog` both style their fieldsets as + `1px solid var(--st-surface-3)` with the legend in `var(--st-text-2)`. This one had a + hand-written `rgba(143, 163, 179, 0.35)` — which is `--st-hair-strong` (0.38) rewritten + by hand — and no legend colour at all, so it inherited. It was the one group in the + panel that did not match the others, and it is what PR20's handoff named as still open. + */ + fieldset { border: 1px solid var(--st-surface-3); border-radius: 4px; padding: 0.3rem 0.5rem; } + legend { font-size: 0.75rem; padding: 0 0.3rem; color: var(--st-text-2); } table.schedule { width: 100%; border-collapse: collapse; margin: 0.5rem 0; } /* A wide schedule scrolls itself instead of stretching the panel. */ .scroll-x { overflow-x: auto; max-width: 100%; } caption { text-align: left; font-weight: 600; padding-bottom: 0.25rem; } - th, td { border: 1px solid rgba(143, 163, 179,0.3); padding: 0.2rem 0.4rem; text-align: right; } + th, td { border: 1px solid var(--st-hair-strong); padding: 0.2rem 0.4rem; text-align: right; } th[scope='col'], td:first-child, td:nth-child(3) { text-align: left; } .documents { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--border, var(--st-text)); } .doc-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } @@ -428,7 +437,7 @@ .badge-reviewed, .badge-issued { background: var(--st-text); color: var(--st-hair-strong); } .superseded-docs { margin-top: 8px; font-size: 12px; } - .review { margin-top: 0.75rem; border-top: 1px solid rgba(143, 163, 179,0.3); padding-top: 0.6rem; } + .review { margin-top: 0.75rem; border-top: 1px solid var(--st-hair-strong); padding-top: 0.6rem; } .disclaimer { font-size: 0.75rem; opacity: 0.8; margin: 0 0 0.4rem; } .field { display: block; margin: 0.35rem 0; } .field input, .field textarea { display: block; width: 100%; max-width: 28rem; padding: 0.25rem 0.4rem; } diff --git a/web/src/lib/__tests__/concrete-design-raw-colours.test.ts b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts new file mode 100644 index 000000000..70e086c85 --- /dev/null +++ b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts @@ -0,0 +1,141 @@ +/** + * Raw colours in the concrete design surface: a debt that can only go down. + * + * ── Why a ceiling and not a ban ──────────────────────────────────── + * + * `design-tokens-resolve.test.ts` already asserts that every `--st-*` a component REFERENCES + * is defined. It cannot see the opposite problem: a component that writes the colour out by + * hand instead of referencing the token at all. Those are invisible to it, and there are 132 + * of them in `components/pro/design/`. + * + * Most are not arbitrary. `DetailingWorkflow` carried `rgba(143, 163, 179, 0.35)`, which is + * `--st-hair-strong` (0.38) rewritten by hand, and `rgba(143, 163, 179, 0.2)`, which is + * `--st-hair` (0.22) rewritten by hand. PR20's own regulations pass found the same thing and + * said so: "`rgba(143, 163, 179, …)` appeared four times, hardcoded beside the tokens that + * mean exactly that." A hand-written approximation drifts from the token the day the token + * changes, and nothing reports it. + * + * Banning them outright today would fail on 132 pre-existing sites across eighteen files, + * several of which are shared surfaces this branch must not touch unilaterally + * (`DesignToolbar`, `OutcomeBadge`) or belong to the 3-D viewer. So this is a CEILING, the + * same shape as `scripts/typecheck-baseline.json`: the count is recorded, it may fall, and it + * may never rise. The debt is visible instead of remembered, and a new component cannot add + * to it. + * + * A file at zero must stay at zero. `DetailingWorkflow` is the first one there. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIR = new URL('../../components/pro/design', import.meta.url).pathname; + +/** + * A literal colour: `#rgb`, `#rrggbb`, `rgb(...)`, `rgba(...)`. + * + * Comments are stripped first. A comment that NAMES the old value — the one this file's own + * fix leaves behind, explaining what it replaced — is documentation, not a colour, and + * counting it would punish writing the reason down. + */ +const COLOUR = /rgba?\(\s*\d|#[0-9a-fA-F]{3,8}\b/g; + +function rawColours(source: string): number { + const stripped = source + .replace(/\/\*[\s\S]*?\*\//g, '') // CSS and JS block comments + .replace(//g, '') // markup comments + .replace(/^\s*\/\/.*$/gm, ''); // JS line comments + return (stripped.match(COLOUR) ?? []).length; +} + +/** + * The recorded ceiling, per file. Lower it when you tokenise; never raise it. + * + * Bucketed by what this branch may touch, because the numbers are not equally actionable: + * + * concrete-only H1 can tokenise these whenever it likes + * shared PRO `OutcomeBadge` is referenced by the metallic status badge and + * `DesignToolbar` is the PRO command row — both need coordination + * 3-D viewer `RebarWorkspace` / `RebarViewport3D` — the viewer is out of scope here + */ +const CEILING: Record = { + // ── concrete-only ── + 'BatchEditDialog.svelte': 3, + 'ConflictInspector.svelte': 10, + 'DesignFamilyPanel.svelte': 2, + 'FloorFamiliesPanel.svelte': 1, + 'FootingCadHandoffPanel.svelte': 3, + 'FootingMatPanel.svelte': 3, + 'FootingMatPhysicalPanel.svelte': 20, + 'ProvisionalBanner.svelte': 4, + 'RebarScenePanel.svelte': 13, + 'RebarStatusPanel.svelte': 19, + 'SectionAdviceDialog.svelte': 2, + 'SelectionDetails.svelte': 9, + 'TorsionBanner.svelte': 4, + 'VerificationDetail.svelte': 3, + // ── shared PRO surface: coordinate before lowering ── + 'DesignToolbar.svelte': 12, + 'OutcomeBadge.svelte': 14, + // ── 3-D viewer: out of scope for this branch ── + 'RebarViewport3D.svelte': 4, + 'RebarWorkspace.svelte': 6, +}; + +const TOTAL_CEILING = 132; + +const files = () => readdirSync(DIR).filter((f) => f.endsWith('.svelte')); + +describe('the raw-colour debt does not grow', () => { + it('no file exceeds its recorded ceiling', () => { + const over: string[] = []; + for (const f of files()) { + const n = rawColours(readFileSync(join(DIR, f), 'utf8')); + const ceiling = CEILING[f] ?? 0; + if (n > ceiling) over.push(`${f}: ${n} raw colours, ceiling ${ceiling}`); + } + expect(over).toEqual([]); + }); + + it('the total does not exceed the recorded total', () => { + const total = files() + .reduce((s, f) => s + rawColours(readFileSync(join(DIR, f), 'utf8')), 0); + expect(total).toBeLessThanOrEqual(TOTAL_CEILING); + }); + + it('a file the ceiling does not list must have none at all', () => { + // This is the half that bites a NEW component: it is absent from the map, so its ceiling + // is zero and it has to use tokens from the first line. + const unlisted = files() + .filter((f) => !(f in CEILING)) + .map((f) => [f, rawColours(readFileSync(join(DIR, f), 'utf8'))] as const) + .filter(([, n]) => n > 0) + .map(([f, n]) => `${f}: ${n}`); + expect(unlisted).toEqual([]); + }); +}); + +describe('the detailing panel is tokenised, and stays that way', () => { + const source = () => readFileSync(join(DIR, 'DetailingWorkflow.svelte'), 'utf8'); + + it('has no raw colours left', () => { + // It had nine, all of them hand-written `rgba(143, 163, 179, α)` — the hair tokens + // rewritten at slightly different alphas — plus a green and a blue of its own. + expect(rawColours(source())).toBe(0); + }); + + it('the sheet fieldset matches the convention the other dialogs already used', () => { + // `ProReportDialog` and `ProAutoLoadsDialog` both use `1px solid var(--st-surface-3)` + // with the legend in `var(--st-text-2)`. This one had a raw border and an uncoloured + // legend, which is what made it the odd group out in the panel. + const s = source(); + expect(s).toMatch(/fieldset\s*\{[^}]*border:\s*1px solid var\(--st-surface-3\)/); + expect(s).toMatch(/legend\s*\{[^}]*color:\s*var\(--st-text-2\)/); + }); + + it('uses the selection token for the selected assembly, not an approximation', () => { + // `--st-selected-bg` exists and means exactly this. The file was writing a translucent + // slate instead, which reads as a hover rather than as a selection. + expect(source()).toMatch(/\.assemblies button\.selected[^}]*var\(--st-selected-bg\)/); + }); +}); From d71436878228e6a9af9c96d76f0ada3b627291fe Mon Sep 17 00:00:00 2001 From: Bauti Date: Thu, 20 Aug 2026 22:37:12 -0300 Subject: [PATCH 04/36] style(design): the footing mat and the rebar rail leave their private palettes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concrete bucket 1, the two files carrying the most raw colour: 39 of the 96. FootingMatPhysicalPanel 20 → 0 RebarStatusPanel 19 → 9 (all nine deliberate; see below) concrete surface 132 → 102 ── Inventoried by role first, and it changed the plan twice ─────── **FootingMatPhysicalPanel** — five slate borders/fills at `rgba(128,128,128, .15/.18/.25/.3)` and eight status bands from `#5c1a1a`/`#ffe4e4` (blocking) and `#7a5b00`/`#fff6dd` (advisory): a red and an amber that exist nowhere else in the application. border → --st-hair-strong for the rule that divides the sub-panel, --st-border for the card and the table cells. Two documented strengths, used as two strengths. surface → --st-surface-2 for `th`. Not chosen for proximity — `DesignTable` already fills a `thead th` with exactly that, and one question should not have two answers. select → --st-selected-bg for `tr.chosen`, which marks the order the design RESOLVED to. That is a selection. The 0.18 grey it replaces read as a faint band, not as "this row governs". status → --st-danger / --st-warn, on the RULE, not on the text. The last one is the only interesting decision, and it is measured rather than asserted. The tidy version — status hue as the text colour — drops a paragraph from **10.80:1** to **4.89:1**. It still clears AA, and halving a sentence's contrast to gain a hue is the wrong side of the trade. `DesignToolbar`'s `.banner-warn` already had the answer: full-contrast text, status-coloured border. **14.4:1** now, better than what it replaced. The badges keep status-coloured TEXT, because there the status word IS the content at 0.68 rem — the case `tokens.css` says the `-text` variants exist for, in its own comment. `.badge.geom-MODELED` stays neutral on `--st-surface-3` and reaches for no status hue at all, which the file's header asks for in as many words: "One green badge must not be able to" pass for a verified result. ── The nine that stay, and why they are not debt ────────────────── `RebarStatusPanel`'s seven state dots are a CONTRACT, and `viewer-design-system.test.ts` already said so: "leaves the state colours alone, because Three.js owns them." Four are mirrored by value in `three/rebar-scene.ts` — `0xe0444a` conflicted, `0xd4762a` unreinforced, `0xa066d3` provisional, `0xffd400` selected — and a material cannot read a custom property, so aliasing the CSS copies would let the picture and the words beside it drift apart. `.element.selected` was the trap: `--st-selected` is vermillion, and taking it would have made the list and the viewport disagree about which member is selected. The other three have nowhere to go. `--st-warn` and `--st-danger` are the only status hues in `tokens.css` and `--st-danger` is already `failed`, so `unsupported`, `designed-not-modelled` and `refused` would have to share — which the panel's own rule forbids: "One colour per state, and never two states sharing one." Frozen whole rather than tokenised by halves, and documented in place so the next pass does not "finish" it. ── A defect that looked like a token ────────────────────────────── Six calls of the shape `var(--text-muted, #8b93a3)`. `--text-muted` is real — an alias on `.workspace` in `RebarWorkspace.svelte` — so the literal never painted anything, and `design-tokens-resolve` is blind to it either way: it checks that referenced `--st-*` tokens exist, and this is not one. Correct value, trap of a form, because it only stays correct while the panel renders inside that one ancestor. `viewer-design-system.test.ts` REQUIRES a fallback on those calls, so a bypassed overlay degrades instead of going unreadable — a contract belonging to `RebarWorkspace`, which this pass does not touch. Both rules hold at once by not reaching for those names: `--st-text`, `--st-text-2` and `--st-hair-strong` are on `:root` and cannot fail to resolve, which is strictly stronger than a fallback. `.workspace` aliases `--st-border: var(--st-hair-strong)`, so the two borders paint the same value they painted yesterday. `#6fa8ff` → `--st-interactive` ("you can click this"), not `--st-focus`, which is nearer by value and is the ring. ── Stopped and not edited ───────────────────────────────────────── `tokens.css` has no `--st-danger-bg` / `--st-warn-bg` and no violet. Both would be additions to a file M1 shares, so no token was invented; the gaps are in the report instead. ── The tests measure the defect, not the diff ───────────────────── `concrete-status-tokens.test.ts`, 15 assertions: the contrast arithmetic above, computed from `tokens.css` by following `var()` aliases to a literal; the named literals absent outside comments; MODELED still neutral; the Three.js mirror asserted in BOTH directions, where the existing test checked one of the four; and the premise of the freeze — four status hues, no violet — so the day a violet is added this fails and points at the work. `footing-status-tokens.spec.ts`, 7 at 1280×720 in en/es/pt: resolved colour against resolved token, plus the negative against `#5c1a1a`/`#7a5b00`, which on this ground are close enough to a dark well that a screenshot would accept either. The bands are asserted per CLASS and the coverage is stated, not implied: `rc-design-qa-8` yields two blocking and no advisory, so the amber branch is source-only, and a `.first()` over both classes would have read as though both were checked. Unchanged and named rather than quietly inherited: blocking vs advisory is distinguished by colour alone, and was before this too. `concrete-design-raw-colours.test.ts` now also lists the files AT zero by name, because "unlisted means zero" is silent about which files those are. Nothing in `DesignToolbar`, `OutcomeBadge`, `ProRibbon`, `StageSection`, `DesignOverview`, the viewer rail, the shared toasts, `tokens.css`, `conn.*`, `profileSelector.*` or the steel locales. The seven floor states and the gradeId classification are untouched. Gates: unit 373 files / 6965 tests · build tests 14 · production build 14.7 s · typecheck 479 against baseline 479 · css-unused warnings 139, identical before and after · footing-status-tokens 7/7 · served at 127.0.0.1:4003. --- web/e2e/footing-status-tokens.spec.ts | 219 +++++++++++++++ .../pro/design/FootingMatPhysicalPanel.svelte | 51 +++- .../pro/design/RebarStatusPanel.svelte | 42 ++- .../concrete-design-raw-colours.test.ts | 26 +- .../__tests__/concrete-status-tokens.test.ts | 258 ++++++++++++++++++ 5 files changed, 570 insertions(+), 26 deletions(-) create mode 100644 web/e2e/footing-status-tokens.spec.ts create mode 100644 web/src/lib/__tests__/concrete-status-tokens.test.ts diff --git a/web/e2e/footing-status-tokens.spec.ts b/web/e2e/footing-status-tokens.spec.ts new file mode 100644 index 000000000..dda4b406f --- /dev/null +++ b/web/e2e/footing-status-tokens.spec.ts @@ -0,0 +1,219 @@ +/** + * The physical mat's status bands are on the token system, and stay legible in every language. + * + * ── What is measured, and why in a browser ───────────────────────── + * + * `concrete-status-tokens.test.ts` proves the SOURCE reaches for `--st-danger` / `--st-warn` / + * `--st-surface-3` and computes the contrast arithmetic from `tokens.css`. Neither of those is + * proof that the page paints it: a token can be shadowed by an ancestor, and a value can resolve + * to something else entirely inside the panel's own cascade. So these compare the RESOLVED + * colour against the RESOLVED token, which is the only assertion that cannot pass by accident. + * + * And the negative: the private literals — `#5c1a1a` blocking, `#7a5b00` advisory — must not be + * what comes back. On a dark ground a `--st-surface-3` well and a dark-red band look close + * enough that a screenshot comparison would accept either. + * + * ── Why three languages ──────────────────────────────────────────── + * + * The bands hold translated sentences from the code messages, not labels. German-length + * compounds are not the risk here; Portuguese and Spanish message text simply runs longer than + * English, and this panel is a two-column grid of `.direction` cards inside the PRO rail at its + * tightest width. A band that wraps to three lines is fine; a panel that grows past 1280 is the + * defect, and only a browser can tell which happened. + */ + +import { test, expect, loadModel, solveModel, computeDemands } from './fixtures'; +import type { Page } from '@playwright/test'; + +const QA = 'rc-design-qa-8'; + +test.use({ viewport: { width: 1280, height: 720 } }); + +/** Resolve a colour string through the browser so a token and an `rgb()` compare equal. */ +const resolve = (page: Page, colour: string) => + page.evaluate((c) => { + const el = document.createElement('span'); + el.style.color = c; + document.body.appendChild(el); + const out = getComputedStyle(el).color; + el.remove(); + return out; + }, colour); + +const token = (page: Page, name: string) => + page.evaluate( + (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(), name); + +/** The token, as the browser finally paints it. */ +const resolvedToken = async (page: Page, name: string) => + resolve(page, await token(page, name)); + +/** + * Reach the physical mat. + * + * The chain is `foundations.spec.ts`'s, gesture for gesture, because the panel only exists once + * a footing has a column, a stratum, a full geometry and a completed floor-design run. Shorter + * routes reach a panel that renders its empty state, which would make every assertion below + * vacuously true. + */ +async function openPhysicalMat(page: Page) { + await loadModel(page, QA); + await solveModel(page); + await computeDemands(page); + + await page.evaluate(() => window.__stabileoActions.openDesignTab()); + const disclosure = page.getByTestId('floor-families-disclosure'); + await expect(disclosure).toBeVisible(); + await disclosure.locator('summary').first().click(); + await page.getByTestId('floor-family-foundations').click(); + await expect(page.getByTestId('foundations-panel')).toBeVisible(); + + await page.getByTestId('soil-add').click(); + const bearing = page.locator('[data-testid^="soil-"][data-testid$="-bearing"]').first(); + await expect(bearing).toBeVisible(); + await bearing.fill('250'); + await bearing.blur(); + + const addNode = page.getByTestId('footing-add-node'); + const node = await addNode.locator('option:not([value=""])').first().getAttribute('value'); + expect(node, 'the fixture must offer a supported node').not.toBeNull(); + await addNode.selectOption(node!); + await expect(page.getByTestId('footing-editor')).toBeVisible(); + + for (const [id, value] of [ + ['footing-B', '2.0'], ['footing-L', '2.0'], ['footing-thickness', '0.5'], + ['footing-cover', '0.05'], ['footing-elevation', '-1.2'], + ] as const) { + const input = page.getByTestId(id); + await input.fill(value); + await input.blur(); + } + + const column = page.getByTestId('footing-column'); + const firstColumn = await column.locator('option:not([value=""])').first().getAttribute('value'); + await column.selectOption(firstColumn!); + const soil = page.getByTestId('footing-soil'); + const firstSoil = await soil.locator('option:not([value=""])').first().getAttribute('value'); + await soil.selectOption(firstSoil!); + + await page.getByTestId('floor-design-run').click(); + await expect(page.getByTestId('footing-mat-physical')).toBeVisible(); +} + +const panel = (page: Page) => page.getByTestId('footing-mat-physical'); + +test.describe('@slow the physical mat paints from tokens', () => { + test('the section rule and the cell borders are the hairline tokens', async ({ pro: page }) => { + await openPhysicalMat(page); + const top = await panel(page).evaluate((el) => getComputedStyle(el).borderTopColor); + expect(top, 'the sub-panel rule is the stronger hairline') + .toBe(await resolvedToken(page, '--st-hair-strong')); + + const cell = panel(page).locator('table th').first(); + if (await cell.count()) { + expect(await cell.evaluate((el) => getComputedStyle(el).borderTopColor)) + .toBe(await resolvedToken(page, '--st-border')); + // And the header is filled with the same token `DesignTable` uses for a `thead th`. + expect(await cell.evaluate((el) => getComputedStyle(el).backgroundColor)) + .toBe(await resolvedToken(page, '--st-surface-2')); + } + }); + + test('the status badge is a token hue, never the private amber or red', + async ({ pro: page }) => { + await openPhysicalMat(page); + const badge = page.getByTestId('footing-mat-geometry-status'); + await expect(badge).toBeVisible(); + const colour = await badge.evaluate((el) => getComputedStyle(el).color); + const bg = await badge.evaluate((el) => getComputedStyle(el).backgroundColor); + + // Whatever state this fixture lands in, the badge is one of the three sanctioned looks. + const [danger, warn, surface3] = await Promise.all([ + resolvedToken(page, '--st-danger'), + resolvedToken(page, '--st-warn'), + resolvedToken(page, '--st-surface-3'), + ]); + expect(bg, 'the badge sits on the well').toBe(surface3); + const inherited = await panel(page).evaluate((el) => getComputedStyle(el).color); + expect([danger, warn, inherited], `badge colour was ${colour}`).toContain(colour); + + // The negative. `#5c1a1a` and `#7a5b00` are close enough to a dark well on this ground + // that a screenshot would not have noticed either way. + for (const gone of ['#5c1a1a', '#7a5b00', '#ffe4e4', '#fff6dd']) { + const literal = await resolve(page, gone); + expect(bg, `${gone} must not be the fill`).not.toBe(literal); + expect(colour, `${gone} must not be the text`).not.toBe(literal); + } + }); + + test('each issue band carries full-contrast text and its own status rule', + async ({ pro: page }) => { + await openPhysicalMat(page); + const [text, danger, warn] = await Promise.all([ + resolvedToken(page, '--st-text'), + resolvedToken(page, '--st-danger'), + resolvedToken(page, '--st-warn'), + ]); + + /* + * Asserted per CLASS, and the coverage is stated rather than implied. + * + * `rc-design-qa-8` produces two blocking findings and no advisory one, so the amber + * branch is exercised at source by `concrete-status-tokens.test.ts` and not here. A + * single `.first()` over both classes would have hidden that: it would have passed on + * the blocking band and read as though both were checked. + */ + const seen: string[] = []; + for (const [cls, rule] of [['blocking', danger], ['advisory', warn]] as const) { + const bands = panel(page).locator(`.issues li.${cls}`); + const n = await bands.count(); + if (n === 0) continue; + seen.push(`${cls}×${n}`); + for (let i = 0; i < n; i++) { + const band = bands.nth(i); + // The message stays at full contrast; that is the whole point of the rule carrying + // the status instead of the text. + expect(await band.evaluate((el) => getComputedStyle(el).color), + `${cls}[${i}] message contrast`).toBe(text); + expect(await band.evaluate((el) => getComputedStyle(el).borderLeftColor), + `${cls}[${i}] status rule`).toBe(rule); + } + } + expect(seen.length, 'this fixture must show at least one band').toBeGreaterThan(0); + test.info().annotations.push( + { type: 'coverage', description: `bands measured in the browser: ${seen.join(', ')}` }); + }); + + test('the resolved order reads as a selection', async ({ pro: page }) => { + await openPhysicalMat(page); + const chosen = panel(page).locator('tr.chosen').first(); + if (!(await chosen.count())) return; + expect(await chosen.evaluate((el) => getComputedStyle(el).backgroundColor)) + .toBe(await resolvedToken(page, '--st-selected-bg')); + }); +}); + +for (const locale of ['en', 'es', 'pt'] as const) { + test.describe(`@slow the mat panel holds 1280×720 in ${locale}`, () => { + test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } }); + + test('the panel does not overflow, whatever the message length', async ({ pro: page }) => { + await openPhysicalMat(page); + /* + * The container, not every descendant. A `.scroll` well and a wide table report + * `scrollWidth > clientWidth` by design — that is what `overflow-x: auto` is for. The + * defect is a panel wider than its own box. + */ + const box = await panel(page) + .evaluate((el) => ({ scroll: el.scrollWidth, client: el.clientWidth })); + expect(box.scroll, `the mat panel fits at 1280 in ${locale}`) + .toBeLessThanOrEqual(box.client + 1); + + // And the status badge stayed inside it, rather than being pushed out by a longer word. + const badge = await page.getByTestId('footing-mat-geometry-status').boundingBox(); + const panelBox = await panel(page).boundingBox(); + expect(badge!.x + badge!.width, `the badge stays in the panel in ${locale}`) + .toBeLessThanOrEqual(panelBox!.x + panelBox!.width + 1); + }); + }); +} diff --git a/web/src/components/pro/design/FootingMatPhysicalPanel.svelte b/web/src/components/pro/design/FootingMatPhysicalPanel.svelte index 3edc04b25..f9cd470bc 100644 --- a/web/src/components/pro/design/FootingMatPhysicalPanel.svelte +++ b/web/src/components/pro/design/FootingMatPhysicalPanel.svelte @@ -377,7 +377,7 @@ diff --git a/web/src/components/pro/design/RebarStatusPanel.svelte b/web/src/components/pro/design/RebarStatusPanel.svelte index c44bae1d5..1a96feaac 100644 --- a/web/src/components/pro/design/RebarStatusPanel.svelte +++ b/web/src/components/pro/design/RebarStatusPanel.svelte @@ -200,7 +200,7 @@ `.rail > *` rule in `RebarWorkspace.svelte`. */ .status { display: flex; flex-direction: column; gap: 0.45rem; } h4, h5 { margin: 0; font-size: 0.82rem; } - .hint { margin: 0; font-size: 0.72rem; color: var(--text-muted, #8b93a3); } + .hint { margin: 0; font-size: 0.72rem; color: var(--st-text-2); } ul { list-style: none; margin: 0; padding: 0; } .counts { display: flex; flex-direction: column; gap: 0.15rem; } .count-row, .element { @@ -209,13 +209,29 @@ padding: 0.22rem 0.4rem; cursor: pointer; text-align: left; color: inherit; font-size: 0.76rem; } - .count-row:hover, .element:hover { background: rgba(255, 255, 255, 0.06); } + /* `--st-surface-3` is the token whose stated job is "inputs, wells, hover states". */ + .count-row:hover, .element:hover { background: var(--st-surface-3); } .count-row.active { border-color: currentColor; } .element.selected { background: rgba(255, 212, 0, 0.16); border-color: #ffd400; } .label, .id { flex: 1 1 auto; } .n, .st { font-variant-numeric: tabular-nums; opacity: 0.85; } .dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; flex: 0 0 auto; } - /* One colour per state, and never two states sharing one. */ + /* + One colour per state, and never two states sharing one. + ───────────────────────────────────────────────────────────────────── + These seven stay LITERAL, deliberately, and `viewer-design-system.test.ts` already says + so: "leaves the state colours alone, because Three.js owns them". Four of them are + mirrored BY VALUE in `three/rebar-scene.ts` — `0xe0444a` conflicted, `0xd4762a` + unreinforced, `0xa066d3` provisional, `0xffd400` selected — and a material cannot read a + custom property, so aliasing the CSS copies would let the picture and the words beside it + drift apart. That is the one thing the colour exists to prevent. + + The other three have no token to go to. `--st-warn` and `--st-danger` are the only two + status hues in `tokens.css`, and `--st-danger` is already spoken for by `failed`; sending + `unsupported`, `designed-not-modelled` and `refused` there would merge states that are + distinct. So the palette is frozen whole rather than tokenised by halves — see the + ceiling entry in `concrete-design-raw-colours.test.ts`. + */ .st-failed .dot { background: #e0444a; } .st-unsupported .dot { background: #b06ad6; } /* The same violet the 3-D view paints provisional steel with — one colour, one meaning, @@ -227,7 +243,7 @@ .cause.hanger { display: flex; width: 100%; } .hanger-chip { font-size: 0.68rem; padding: 0 0.28rem; border-radius: 3px; - border: 1px solid #6c6c6c; color: #b9b9b9; white-space: nowrap; + border: 1px solid var(--st-hair-strong); color: var(--st-text-2); white-space: nowrap; } .st-refused .dot { background: #d4762a; } .st-designed-not-modelled .dot { background: #d9c04a; } @@ -247,22 +263,30 @@ .elements { flex: 0 0 auto; } .reason { margin: 0 0 0.25rem 1.4rem; font-size: 0.7rem; - color: var(--text-muted, #8b93a3); + color: var(--st-text-2); } /* The shared cause sits UNDER its state row and indented to it, so it reads as an explanation of that count rather than as another state. */ .cause { display: flex; align-items: baseline; gap: 0.35rem; width: 100%; margin: 0 0 0.2rem 1.4rem; padding: 0.1rem 0.3rem; - background: none; border: none; border-left: 2px solid var(--st-border, #2c3444); - color: var(--text-muted, #8b93a3); font-size: 0.7rem; line-height: 1.35; + /* `--st-hair-strong`, not `var(--st-border, #2c3444)`. + `viewer-design-system.test.ts` requires a fallback on every `--text` / `--text-muted` / + `--st-border` / `--panel` call in a viewer panel, so a bypassed overlay degrades instead + of going unreadable. `.workspace` aliases `--st-border: var(--st-hair-strong)`, so this + is the value that renders today either way — and naming it directly satisfies that + contract outright rather than insuring against it, because `--st-hair-strong` is on + `:root` and cannot fail to resolve. Same for `.hanger-chip` above. */ + background: none; border: none; border-left: 2px solid var(--st-hair-strong); + color: var(--st-text-2); font-size: 0.7rem; line-height: 1.35; text-align: left; cursor: pointer; } - .cause:hover { color: var(--text, #d7dce6); border-left-color: #6fa8ff; } + /* `--st-interactive` — "you can click this" — not `--st-focus`, which is the ring. */ + .cause:hover { color: var(--st-text); border-left-color: var(--st-interactive); } .cause-n { flex: none; font-variant-numeric: tabular-nums; font-weight: 600; } .cause-text { min-width: 0; } .link { - background: none; border: none; padding: 0; color: #6fa8ff; + background: none; border: none; padding: 0; color: var(--st-interactive); font-size: 0.74rem; cursor: pointer; text-align: left; } diff --git a/web/src/lib/__tests__/concrete-design-raw-colours.test.ts b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts index 70e086c85..86803d28e 100644 --- a/web/src/lib/__tests__/concrete-design-raw-colours.test.ts +++ b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts @@ -66,10 +66,12 @@ const CEILING: Record = { 'FloorFamiliesPanel.svelte': 1, 'FootingCadHandoffPanel.svelte': 3, 'FootingMatPanel.svelte': 3, - 'FootingMatPhysicalPanel.svelte': 20, 'ProvisionalBanner.svelte': 4, 'RebarScenePanel.svelte': 13, - 'RebarStatusPanel.svelte': 19, + // 9 left, all of them the state palette: four mirrored by value in + // `three/rebar-scene.ts` and three with no token to go to. See + // `concrete-status-tokens.test.ts`, which asserts the contract in both directions. + 'RebarStatusPanel.svelte': 9, 'SectionAdviceDialog.svelte': 2, 'SelectionDetails.svelte': 9, 'TorsionBanner.svelte': 4, @@ -82,7 +84,7 @@ const CEILING: Record = { 'RebarWorkspace.svelte': 6, }; -const TOTAL_CEILING = 132; +const TOTAL_CEILING = 102; // was 132: −20 FootingMatPhysicalPanel, −10 RebarStatusPanel const files = () => readdirSync(DIR).filter((f) => f.endsWith('.svelte')); @@ -115,6 +117,24 @@ describe('the raw-colour debt does not grow', () => { }); }); +/** + * The files that are AT zero, listed by name. + * + * A file at zero is only protected by the ceiling map's "unlisted means zero" rule, which is + * silent about which files that is. Naming them makes the set visible, so tokenising one is a + * line in this list rather than a deletion nobody reads. + */ +describe('the files already at zero stay there', () => { + const AT_ZERO = ['DetailingWorkflow.svelte', 'FootingMatPhysicalPanel.svelte']; + + it('each of them still has none', () => { + for (const f of AT_ZERO) { + expect(rawColours(readFileSync(join(DIR, f), 'utf8')), f).toBe(0); + expect(f in CEILING, `${f} must not be given a ceiling again`).toBe(false); + } + }); +}); + describe('the detailing panel is tokenised, and stays that way', () => { const source = () => readFileSync(join(DIR, 'DetailingWorkflow.svelte'), 'utf8'); diff --git a/web/src/lib/__tests__/concrete-status-tokens.test.ts b/web/src/lib/__tests__/concrete-status-tokens.test.ts new file mode 100644 index 000000000..02b09e82d --- /dev/null +++ b/web/src/lib/__tests__/concrete-status-tokens.test.ts @@ -0,0 +1,258 @@ +/** + * The concrete panels take their status colours from the token system — and where they cannot, + * they say so. + * + * ── The two defects this pins ────────────────────────────────────── + * + * **A private status palette.** `FootingMatPhysicalPanel` painted eight status bands from + * `#5c1a1a`/`#ffe4e4` (blocking) and `#7a5b00`/`#fff6dd` (advisory) — a red and an amber that + * exist nowhere else in the application. `tokens.css` has `--st-danger` and `--st-warn` and no + * surface variants of either, so the band became a `--st-surface-3` well with the status on its + * left rule. That trade is measured below rather than asserted: the obvious version, status + * hue as the TEXT colour, would have cut a paragraph from 10.80:1 to 4.89:1. + * + * **A dead fallback that looks like a token.** `RebarStatusPanel` had six calls of the shape + * `var(--text-muted, #8b93a3)`. `--text-muted` IS defined — as an alias on `.workspace` in + * `RebarWorkspace.svelte` — so the literal never painted anything, and + * `design-tokens-resolve.test.ts` cannot see it either way: it checks that referenced `--st-*` + * tokens exist and this is not one. The value was correct and the form was a trap, because it + * only stays correct while the panel renders inside that one ancestor. + * + * ── And the part that stays literal on purpose ───────────────────── + * + * The seven state dots are NOT debt. Four are mirrored by value in `three/rebar-scene.ts`, + * which feeds hex numbers to Three.js materials and cannot read a custom property. The mirror + * is asserted here in both directions, which `viewer-design-system.test.ts` did for one of the + * four. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const DESIGN = new URL('../../components/pro/design', import.meta.url).pathname; +const read = (f: string) => readFileSync(join(DESIGN, f), 'utf8'); +const TOKENS = readFileSync( + new URL('../../styles/tokens.css', import.meta.url).pathname, 'utf8'); + +/** Follow a token through its `var()` aliases until a literal falls out. */ +function resolveToken(name: string, depth = 0): string { + expect(depth, `${name} does not resolve to a literal`).toBeLessThan(8); + const m = TOKENS.match(new RegExp(`${name}\\s*:\\s*([^;]+);`)); + expect(m, `${name} must be defined in tokens.css`).not.toBeNull(); + const value = m![1].trim(); + const alias = value.match(/^var\((--[a-z0-9-]+)\)$/); + return alias ? resolveToken(alias[1], depth + 1) : value; +} + +/** sRGB → relative luminance, WCAG 2.1 §1.4.3. */ +function luminance(hex: string): number { + const h = hex.replace('#', ''); + const ch = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); + const lin = ch.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; +} + +const contrast = (a: string, b: string) => { + const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); + return (x + 0.05) / (y + 0.05); +}; + +describe('the trade the footing bands actually made', () => { + it('the message keeps more contrast than the private band it replaced, not less', () => { + const surface = resolveToken('--st-surface-3'); + const text = resolveToken('--st-text'); + const now = contrast(text, surface); + // `#ffe4e4` on `#5c1a1a` — the literal pair that is gone. + const before = contrast('#ffe4e4', '#5c1a1a'); + expect(now, 'full-contrast text on the well').toBeGreaterThan(before); + expect(now).toBeGreaterThan(10); + }); + + it('and records why the status hue is NOT the text colour', () => { + // This is the version that would have been the tidy one. It passes AA and still loses to + // the band, which is the whole reason the rule carries the status instead. + const tinted = contrast(resolveToken('--st-danger'), resolveToken('--st-surface-3')); + expect(tinted, 'the tinted variant does clear AA').toBeGreaterThan(4.5); + expect(tinted, 'and is still worse than what it replaced').toBeLessThan( + contrast('#ffe4e4', '#5c1a1a')); + }); + + it('the badges DO use the status hue as text, and that still clears AA', () => { + // A badge's content is the status word itself, at 0.68rem — the case `tokens.css` says the + // `-text` variants exist for. + for (const t of ['--st-danger', '--st-warn']) { + expect(contrast(resolveToken(t), resolveToken('--st-surface-3')), t) + .toBeGreaterThanOrEqual(4.5); + } + }); +}); + +describe('the footing panel carries no private palette', () => { + const src = () => read('FootingMatPhysicalPanel.svelte'); + const css = () => src().replace(/\/\*[\s\S]*?\*\//g, ''); + + it('none of the eight band literals survive outside a comment', () => { + // Kept as an explicit list: a regex for "any colour" would pass the day someone mixes a + // ninth one, and these five are the specific values that were there. + for (const lit of ['#5c1a1a', '#ffe4e4', '#7a5b00', '#fff6dd', 'rgba(128,128,128']) { + expect(css(), `${lit} must be gone`).not.toContain(lit); + } + }); + + it('blocking is danger and advisory is warn, and neither is the other', () => { + const c = css(); + expect(c).toMatch(/\.issues li\.blocking\s*\{[^}]*border-left:[^;]*var\(--st-danger\)/); + expect(c).toMatch(/\.issues li\.advisory\s*\{[^}]*border-left:[^;]*var\(--st-warn\)/); + // Blocking is never green, which is the file's own rule and the one worth a test. + expect(c).not.toMatch(/\.issues li\.blocking\s*\{[^}]*--st-ok/); + }); + + it('the MODELED badge stays neutral — no status hue, and above all not green', () => { + // The panel header's own words: "One green badge must not be able to" stand in for a + // verified result. A surface and nothing else. + const rule = css().match(/\.badge\.geom-MODELED[^{]*\{([^}]*)\}/); + expect(rule).not.toBeNull(); + expect(rule![1]).toContain('var(--st-surface-3)'); + for (const t of ['--st-ok', '--st-green', '--st-danger', '--st-warn']) { + expect(rule![1], `MODELED must not reach for ${t}`).not.toContain(t); + } + }); + + it('and the failed / not-evaluated badges do not share one hue', () => { + const c = css(); + expect(c).toMatch(/\.badge\.geom-RECONCILIATION_FAILED[^}]*var\(--st-danger\)/); + expect(c).toMatch(/\.badge\.geom-NOT_MODELED[^}]*var\(--st-warn\)/); + }); + + it('the resolved order is marked as a selection, not as a lighter grey', () => { + expect(css()).toMatch(/tr\.chosen\s*\{[^}]*var\(--st-selected-bg\)/); + }); +}); + +describe('no concrete design panel hides a literal behind a fallback', () => { + /** + * `var(--anything, #literal)` is the shape that defeated the existing token gate. Either the + * custom property resolves — and the literal is dead weight the next person reads as the + * intended value — or it does not, and the panel is off the system while looking like it is + * on it. + * + * `viewer-design-system.test.ts` requires the opposite for four names — `--text`, + * `--text-muted`, `--st-border`, `--panel` — because those are declared only on + * `.workspace`, so a viewer panel rendered outside it would lose them. Both rules hold at + * once by not reaching for those four: `--st-text`, `--st-text-2` and `--st-hair-strong` are + * on `:root` and cannot fail, which is a stronger guarantee than a fallback is. + */ + const PANELS = ['RebarStatusPanel.svelte', 'FootingMatPhysicalPanel.svelte']; + + /** + * Only two of the four are genuinely undefined at `:root`. + * + * `--text`, `--text-muted` and `--panel` exist nowhere but `.workspace`, so any panel using + * them is betting on an ancestor. `--st-border` is different: it IS defined at `:root`, and + * `.workspace` merely SHADOWS it with `--st-hair-strong`. So a panel outside the overlay may + * use it freely — `FootingMatPhysicalPanel` does, for its card and cell borders — while one + * inside must not, because there it silently means the stronger hairline. + */ + const UNDEFINED_AT_ROOT = ['--text', '--text-muted', '--panel']; + const SHADOWED_IN_OVERLAY = ['--st-border']; + const VIEWER = new Set(['RebarStatusPanel.svelte']); + + it('neither of the two panels this pass tokenised carries a fallback literal', () => { + const bad: string[] = []; + for (const f of PANELS) { + const css = read(f).replace(/\/\*[\s\S]*?\*\//g, ''); + for (const m of css.matchAll(/var\(\s*--[a-z0-9-]+\s*,\s*(#[0-9a-fA-F]{3,8}|rgba?\()/g)) { + bad.push(`${f}: ${m[0]}`); + } + } + expect(bad).toEqual([]); + }); + + it('and neither depends on a property that only the overlay defines', () => { + // Which is what makes dropping the fallbacks safe rather than a rule broken in the viewer's + // favour: what is left cannot fail to resolve. + for (const f of PANELS) { + const css = read(f).replace(/\/\*[\s\S]*?\*\//g, ''); + const forbidden = VIEWER.has(f) + ? [...UNDEFINED_AT_ROOT, ...SHADOWED_IN_OVERLAY] + : UNDEFINED_AT_ROOT; + for (const name of forbidden) { + expect(css, `${f} must not depend on ${name}`) + .not.toMatch(new RegExp(`var\\(\\s*${name}\\s*[,)]`)); + } + } + }); + + it('every token these panels do reach for is defined at :root', () => { + // The property `design-tokens-resolve` holds for `--st-*`, restated here over the exact set + // this pass introduced — including the non-`--st-` names, which that gate does not see. + for (const f of PANELS) { + const css = read(f).replace(/\/\*[\s\S]*?\*\//g, ''); + for (const m of css.matchAll(/var\(\s*(--[a-z0-9-]+)\s*\)/g)) { + expect(TOKENS, `${f}: ${m[1]}`).toMatch(new RegExp(`${m[1]}\\s*:`)); + } + } + }); +}); + +describe('the rebar state palette is a contract with the 3-D scene, not debt', () => { + const panel = () => read('RebarStatusPanel.svelte'); + const scene = () => readFileSync( + new URL('../three/rebar-scene.ts', import.meta.url).pathname, 'utf8'); + + /** state class in the panel → the name Three.js gives the same colour. */ + const MIRRORED = [ + ['.st-failed', 'conflicted', 'e0444a'], + ['.st-refused', 'unreinforced', 'd4762a'], + ['.st-provisional', 'provisional', 'a066d3'], + ] as const; + + it('every mirrored state holds the same value on both sides', () => { + const p = panel(); + const s = scene(); + for (const [cls, sceneKey, hex] of MIRRORED) { + expect(p, `${cls} in the panel`).toMatch( + new RegExp(`${cls.replace('.', '\\.')} \\.dot \\{ background: #${hex};`)); + expect(s, `${sceneKey} in the scene`).toMatch( + new RegExp(`${sceneKey}:\\s*0x${hex}`)); + } + }); + + it('the selected element agrees with the viewport highlight too', () => { + // Not a state, but the same class of contract: `0xffd400` paints the selection in the + // scene, so the panel row cannot become `--st-selected` (vermillion) without the list and + // the picture disagreeing about which member is selected. + expect(panel()).toContain('#ffd400'); + expect(scene()).toMatch(/selected:\s*0xffd400/); + expect(panel(), 'and must not switch to the generic selection token') + .not.toMatch(/\.element\.selected[^}]*var\(--st-selected\)/); + }); + + it('the three panel-only states have no token to go to, and that is why they stay', () => { + /** + * `unsupported`, `designed-not-modelled` and `not-evaluated` are not in the scene. They + * stay literal because `tokens.css` offers exactly two status hues, `--st-warn` and + * `--st-danger`, and `--st-danger` is already `failed`. Sending two of these to `--st-warn` + * would merge states the panel's own comment forbids merging: "One colour per state, and + * never two states sharing one." + * + * This asserts the PREMISE, so the day a violet or a second amber is added to the token + * system this test fails and points at the work. + */ + const statusHues = [...TOKENS.matchAll(/--st-(warn|danger|ok|info):/g)].map((m) => m[1]); + expect(new Set(statusHues), 'the status vocabulary is still four wide') + .toEqual(new Set(['warn', 'danger', 'ok', 'info'])); + expect(TOKENS, 'no violet exists yet').not.toMatch(/--st-(violet|purple|provisional):/); + // And the panel still writes them out, rather than having quietly picked a near-match. + for (const hex of ['#b06ad6', '#d9c04a', '#8b93a3']) { + expect(panel()).toContain(hex); + } + }); + + it('the state palette is documented in place as frozen, not merely left behind', () => { + // A literal with no explanation is indistinguishable from one nobody got to. + const p = panel(); + expect(p).toMatch(/Three\.js owns them|mirrored BY VALUE/); + }); +}); From 54ba502325f83a50052561f049dbc99271ec746e Mon Sep 17 00:00:00 2001 From: Bauti Date: Thu, 20 Aug 2026 23:20:20 -0300 Subject: [PATCH 05/36] style(design): the viewer's concrete panels join the token system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concrete bucket 1, the three panels named plus one that could not be left behind. RebarScenePanel 13 → 11 ConflictInspector 10 → 5 SelectionDetails 9 → 5 TorsionBanner 4 → 0 ─────────────────────────── 36 → 21 concrete surface 102 → 87 ── The inventory changed what was worth doing ───────────────────── Most of what the ceiling map counted in these files is not debt. Sorted by role rather than by count: contract, alias fallback 11 `var(--text-muted, #8b93a3)` and one `var(--st-border, #2c3444)` per panel contract, Three.js mirror 10 six state dots, the conflicted count, the unreinforced rule, the band fill actionable 15 text, borders, hovers, one filled button `RebarScenePanel` is the reason the fallbacks stay, and it is not a formality: that panel mounts in `RebarWorkspace` AND in `DocumentsSection`. Outside the overlay `--text-muted` is not defined at all, so its `#8b93a3` is the value that paints. The fallback is load-bearing, and `viewer-design-system.test.ts` requires it for exactly that reason. So the actionable share of `RebarScenePanel` was two literals, and saying that plainly is better than tokenising a mirror to make a number move. ── What moved ───────────────────────────────────────────────────── `RebarScenePanel`'s filled button → `--st-blue` + `--st-text-on-accent`. NOT `--st-accent`, which `tokens.css` documents as "primary action, brand" and which this application also fills its DESTRUCTIVE buttons with — `ProReportDialog`'s `.rpt-btn-danger`, `ProAutoLoadsDialog`'s `.al-error`, `ProMaterialsTab`'s `.agg-error`. A vermillion "open workspace" would read as a warning. `--st-blue` is `#2c6cb4` against the `#2b6cb0` that was there, and `--st-text-on-accent` is exactly the `#fff` it replaces. `ConflictInspector` had two pinks encoding two severities: `#ffb0b6` on every header and a brighter `#ff6b74` on the strong when the class is `overlap`. The DIFFERENCE is what carries meaning, so the base went to `--st-text` and the emphasis to `--st-danger`. The other way round — base `--st-danger`, nothing stronger left for overlap — would have merged interpenetration with a spacing shortfall, which is the distinction the file's own comment exists to state. Its band keeps `#e0444a` and the 0.14 fill of that same hue: `conflicted: 0xe0444a` in the scene. The SENTENCE took `--st-text`, which is the trade `FootingMatPhysicalPanel` measured last week — a status hue as body text costs more contrast than it buys. ── The pair that could not be half-done ─────────────────────────── `SelectionDetails` carried `#f2ddc6`/`#ffbe7a` under the comment "The same amber the workspace banner uses. One colour, one meaning", and `TorsionBanner` carried the identical two. Tokenising one would have broken precisely the equality that comment asserts, so both moved to `--st-text` + `--st-warn` together. `TorsionBanner` is outside the three files named for this pass. It is concrete-only, mounts nowhere but `RebarWorkspace`, and M1 does not reference it — so this is a scope addition inside bucket 1, not a shared-surface edit. Its border also stopped borrowing `#d4762a`: that is `unreinforced: 0xd4762a`, and a torsion advisory and an unreinforced bar are unrelated states that happened to share an orange. Only one of the two is a scene contract. ── Three corrections, each of which had passed ──────────────────── **A locator that measured the wrong element.** `.sel-actions button, .actions button` resolved to `review-submit` — a "Record review" button belonging to another component, sitting UNDER the workspace canvas. `hover()` reported the truth (the canvas intercepts pointer events) and timed out; the sibling test that only READ a computed style measured that foreign button and PASSED. Both are now scoped to `rebar-workspace`, and the hover test annotates which button it measured. **`doc-3d` does not reveal the Documents panel, it opens the workspace over it.** The first draft then clicked `rebar-open-workspace` to open something already open, and waited three minutes on a covered button. **A contrast assertion with the wrong ground.** `--st-interactive` on `--st-surface-3` is **4.36:1** — under AA for text. It is used only as `border-color`, where WCAG 2.1 §1.4.11 asks 3:1, with `--st-text` beside it for the label. Recorded, along with the fact that the `#6fa8ff` it replaces measured 6.17:1: real headroom was traded for system membership, and that is worth being able to see rather than discover. ── Coverage stated, not implied ─────────────────────────────────── `viewer-panel-tokens.spec.ts`, 9 at 1280×720, en/es/pt on the Documents route: resolved colour against resolved token, the negative against each literal, the `--st-border` shadow proved LIVE inside the overlay (`.workspace` aliases it to `--st-hair-strong`, so one declaration paints two values and only the browser knows which), and the hover rule proved to fire at all. The first version of the torsion test reported "none of the three appeared on this fixture — source coverage only". True and useless: `rc-design-qa-8` designs without torsion. `rc-qa-diagnostic` raises the banner — `rebar-toggles.spec.ts` relies on the same fact — so the amber pair is now measured where it exists, banner AND selection line. What is still source-only: `ConflictInspector`'s band, and five of the six state dots (the fixture shows `modelled`). Both annotated in the run. `concrete-status-tokens.test.ts` grew to 26: the mirror asserted in both directions across all three panels, the amber pair held equal through the tokens, the two contrast bars kept apart, and a hex-shorthand bug in its own luminance helper fixed — `#fff` sliced two characters at a time yields NaN, and `NaN >= 4.5` is false, so it failed for the right reason and the wrong cause. ── And a proposal, not a commit ─────────────────────────────────── `docs/handoffs/h1-shared-status-tokens-proposal.md`. `--st-danger-bg`, `--st-warn-bg` and three provisional tokens, with values derived from the palette (`--st-red`/`--st-amber` at the `--st-vermillion-dim` alpha), contrast computed over the COMPOSITE on each real ground, the uses in H1 and M1, the Three.js constraint, and a six-step migration. It carries the product recommendation to align provisional with the scene's `0xa066d3` — with one measured qualification. `#a066d3` does not clear AA as text: 4.30 on `--st-surface`, 3.77 on `--st-surface-3`. So the alignment is by IDENTITY, with the two-strength split `tokens.css` already documents — `--st-provisional` for the dot and the mesh, `--st-provisional-text` `#c08ae6` (6.46) for labels. Recommending the flat value would have shipped a legibility regression under the banner of consistency. `tokens.css`, `DesignToolbar`, `OutcomeBadge`, `SteelStatusBadge` and `ProvisionalBanner` are untouched. So are `ProRibbon`, `StageSection`, `DesignOverview`, `RebarWorkspace`, the shared toasts, `conn.*`, `profileSelector.*` and the steel locales. The seven floor states and the gradeId classification are unchanged. Gates: unit 373 files / 6976 tests · build tests 14 · production build 14.7 s · typecheck 479 against baseline 479 · css-unused warnings 139, identical before and after · viewer-panel-tokens 9/9 · served at 127.0.0.1:4003. --- .../h1-shared-status-tokens-proposal.md | 220 ++++++++++ web/e2e/viewer-panel-tokens.spec.ts | 401 ++++++++++++++++++ .../pro/design/ConflictInspector.svelte | 24 +- .../pro/design/RebarScenePanel.svelte | 19 +- .../pro/design/SelectionDetails.svelte | 10 +- .../pro/design/TorsionBanner.svelte | 10 +- .../concrete-design-raw-colours.test.ts | 21 +- .../__tests__/concrete-status-tokens.test.ts | 181 +++++++- 8 files changed, 864 insertions(+), 22 deletions(-) create mode 100644 docs/handoffs/h1-shared-status-tokens-proposal.md create mode 100644 web/e2e/viewer-panel-tokens.spec.ts diff --git a/docs/handoffs/h1-shared-status-tokens-proposal.md b/docs/handoffs/h1-shared-status-tokens-proposal.md new file mode 100644 index 000000000..4f9c233a8 --- /dev/null +++ b/docs/handoffs/h1-shared-status-tokens-proposal.md @@ -0,0 +1,220 @@ +# Propuesta para M1 — tres tokens de estado que faltan en `tokens.css` + +**Origen:** H1 (`feat/pro-concrete-h1`), tokenización de la cubeta 1 de hormigón. +**Estado:** propuesta. **Nada de esto está implementado.** `tokens.css`, `DesignToolbar.svelte`, +`OutcomeBadge.svelte`, `SteelStatusBadge.svelte` y `ProvisionalBanner.svelte` están sin tocar. +**Decisión pendiente:** de Bauti y Diego. `tokens.css` es superficie compartida H1/M1. + +--- + +## 1. El problema, medido + +`tokens.css` define **cuatro matices de estado** —`--st-ok`, `--st-warn`, `--st-danger`, +`--st-info`— todos pensados como **color de texto o de trazo**. No define ninguna **superficie** +de estado, y no define violeta. + +Consecuencia observable: cada componente que necesitó una banda de estado se mezcló la suya a +mano. Inventario real, contado sobre el árbol en `d7143687`: + +| Archivo | Literales | Qué son | Dueño | +|---|---:|---|---| +| `OutcomeBadge.svelte` | 14 | `rgba(221,170,0,.16)`, `rgba(255,102,0,.16)`, … rellenos de badge | **compartido** (`SteelStatusBadge` lo referencia) | +| `DesignToolbar.svelte` | 12 | `rgba(255,102,0,.13)` en `.banner-warn`, … | **compartido** (fila de comandos PRO) | +| `ProvisionalBanner.svelte` | 4 | `rgba(160,102,211,.16)`, `#e2d3f5`, `#d8b4ff` | hormigón | +| `VerificationDetail.svelte` | 3 | `rgba(255,102,0,.08)` | hormigón | +| `FootingMatPhysicalPanel.svelte` | ~~8~~ 0 | `#5c1a1a`/`#ffe4e4`, `#7a5b00`/`#fff6dd` | **ya resuelto en H1** sin token nuevo | + +Los cuatro naranjas `rgba(255,102,0, α)` de `DesignToolbar`, `OutcomeBadge` y +`VerificationDetail` son **el mismo color a tres alfas distintas**, y ninguno de los tres es +`--st-amber` (`#b8860b`) ni `--st-warn` (`#d9a441`). Es una quinta familia de ámbar que existe +sólo en esos archivos. + +### Por qué H1 no lo necesitó, y por qué eso no escala + +`FootingMatPhysicalPanel` resolvió sus ocho bandas **sin token nuevo**: pozo `--st-surface-3` + +regla izquierda con el matiz + texto en `--st-text`. Medido: el párrafo pasó de **10.80:1** a +**14.43:1**. Funciona, y es la forma que `DesignToolbar.banner-warn` ya usaba. + +Lo que **no** cubre es el *badge*, donde el relleno teñido es la señal (no hay espacio para una +regla de 3 px en un chip de 0.68 rem). Ahí `OutcomeBadge` seguirá mezclando `rgba()` a mano +mientras no exista una superficie de estado. + +--- + +## 2. Los tres tokens + +Valores **derivados de la paleta existente**, no inventados: el matiz base ya está en +`tokens.css` y el alfa copia el único precedente que hay, `--st-vermillion-dim` a `0.14`. + +```css +/* ── Superficies de estado ──────────────────────────────────────────── + El mismo patrón que --st-vermillion-dim / --st-selected-bg: el matiz + base de la paleta, a un alfa bajo, para que el fondo del panel siga + leyéndose debajo. */ +--st-danger-bg: rgba(192, 57, 43, 0.14); /* = --st-red #c0392b */ +--st-warn-bg: rgba(184, 134, 11, 0.16); /* = --st-amber #b8860b */ + +/* ── Provisional ────────────────────────────────────────────────────── + Dos fuerzas, igual que el resto de la paleta: el matiz para rellenos y + figuras, la variante -text para etiquetas chicas. */ +--st-provisional: #a066d3; /* = 0xa066d3, el valor de Three.js */ +--st-provisional-text: #c08ae6; /* 6.46 sobre --st-surface */ +--st-provisional-bg: rgba(160, 102, 211, 0.16); /* lo que ProvisionalBanner ya usa */ +``` + +### Contraste calculado + +Composite del rgba sobre cada fondo real, y después el contraste de lo que va encima. +Todos los números salen de `concrete-status-tokens.test.ts`, que hace esta misma aritmética +siguiendo los alias de `tokens.css` hasta el literal. + +| Token | Composite sobre `--st-surface` | `--st-text` encima | `--st-text-2` encima | El matiz `-text` encima | +|---|---|---:|---:|---:| +| `--st-danger-bg` | `#28222b` | **14.43** | 5.95 | 5.11 (`--st-danger`) | +| `--st-warn-bg` | `#2a2f26` | **12.74** | 5.26 | 6.09 (`--st-warn`) | +| `--st-provisional-bg` | `#262a46` | **13.00** | 5.36 | 5.34 (`--st-provisional-text`) | + +Los tres pasan AA con cualquiera de las tres combinaciones. Ninguno obliga a elegir entre +legibilidad y matiz, que es exactamente el canje que `FootingMatPhysicalPanel` tuvo que medir +por no tener estos tokens. + +### La corrección a la recomendación de producto + +**La recomendación se sostiene, con una salvedad que hay que dejar escrita.** + +Alinear *provisional* con el violeta de Three.js (`0xa066d3`) es correcto: hoy el mismo estado +tiene **dos significados visuales** —`ProvisionalBanner` y `RebarStatusPanel` lo pintan violeta, +`FloorFamilyStateCard` lo manda a `--st-warn`— y eso es peor que cualquiera de los dos. + +Pero **`#a066d3` no pasa AA como texto**: + +| `#a066d3` sobre | Contraste | ¿AA texto chico? | +|---|---:|---| +| `--st-bg` `#0c1620` | 4.63 | apenas | +| `--st-surface` `#0f1e2b` | **4.30** | **no** | +| `--st-surface-3` `#17293a` | **3.77** | **no** | +| `--st-provisional-bg` | **3.55** | **no** | + +Así que la alineación tiene que ser **por identidad, no por valor literal en todos los roles**: +`--st-provisional` = `#a066d3` para el **dot, el relleno y la malla** (que es donde Three.js +manda, y donde el área carga el significado), y `--st-provisional-text` = `#c08ae6` para las +**etiquetas**. Es el mismo desdoblamiento que `tokens.css` ya documenta para los otros cuatro +matices, en sus propias palabras: *"the `-text` variants are the ones that clear WCAG AA as +small UI text on the dark ground; the plain ones are for fills, rules and figures, where area +carries the meaning."* + +Nota: `ProvisionalBanner` hoy usa `#e2d3f5` (9.90) y `#d8b4ff` (7.92), los dos **más claros** que +`#c08ae6`. Si se prefiere no perder ese contraste, `--st-provisional-text: #d8b4ff` también +sirve y da 7.92 sobre la superficie propuesta. `#c08ae6` está elegido por coherencia con el +resto de la paleta (los `-text` viven entre 5.3 y 7.3), no por ser el máximo. + +--- + +## 3. Usos concretos + +### En H1 (hormigón) + +| Archivo | Hoy | Con los tokens | +|---|---|---| +| `ProvisionalBanner.svelte` | 4 literales | 0 — es el uso canónico de los tres provisional | +| `VerificationDetail.svelte` | `rgba(255,102,0,.08)` | `--st-warn-bg` | +| `FloorFamilyStateCard.svelte` | `provisional` → `--st-warn` | → `--st-provisional`, y se cierra la discrepancia | +| `RebarStatusPanel.svelte` | `.st-provisional` `#a066d3` literal | **sigue literal** (ver §4) | +| `FootingMatPhysicalPanel.svelte` | ya tokenizado sin ellos | sin cambios; los badges *podrían* pasar a `--st-danger-bg` | + +### En M1 (metálicas) — a confirmar con Diego + +| Archivo | Hoy | Con los tokens | +|---|---|---| +| `OutcomeBadge.svelte` | 14 literales, incluidos 2 rellenos teñidos | los rellenos → `--st-warn-bg` / `--st-danger-bg` | +| `SteelStatusBadge.svelte` | referencia `OutcomeBadge` | hereda sin editarse | +| `DesignToolbar.svelte` | 12, incluido `.banner-warn` | `.banner-warn` → `--st-warn-bg` | + +`OutcomeBadge` es el único archivo que **las dos ramas** necesitan editar. Es la razón por la que +H1 no lo tocó y por la que esto es una propuesta y no un commit. + +--- + +## 4. Impacto sobre Three.js + +**Ninguno, si se respeta una regla: el número sigue siendo la autoridad.** + +`src/lib/three/rebar-scene.ts` alimenta materiales con hex numéricos y **no puede leer una custom +property**. Los valores que espeja hoy: + +``` +conflicted: 0xe0444a unreinforced: 0xd4762a +selected: 0xffd400 provisional: 0xa066d3 +``` + +Dos tests ya fijan ese espejo —`viewer-design-system.test.ts` (*"leaves the state colours alone, +because Three.js owns them"*) y `run-summary-reported.test.ts`— y H1 agregó +`concrete-status-tokens.test.ts`, que lo asserta **en las dos direcciones** y en tres paneles. + +Por eso: + +- `--st-provisional: #a066d3` **duplica** el valor de la escena en CSS. Eso es aceptable **sólo + si un test lo mantiene igualado**. Ver §5. +- Los dots de `RebarStatusPanel` y `RebarScenePanel` **no se tokenizan** aunque el token exista. + Un `var()` en el CSS y un `0x` en el material se pueden separar en silencio; un literal + duplicado con un test que los compara, no. +- La alternativa —que `rebar-scene.ts` lea el token en runtime con + `getComputedStyle(document.documentElement)`— es posible pero **no la recomiendo acá**: + agrega una dependencia del DOM a un módulo que hoy es puro y testeable sin navegador. + +--- + +## 5. Migración y tests + +**Orden propuesto. Cada paso deja el árbol verde.** + +1. **`tokens.css`** — agregar los cinco tokens. Sin cambiar ningún componente. + `design-tokens-resolve.test.ts` sigue pasando (sólo verifica que lo referenciado exista); + los techos de colores crudos no se mueven. +2. **El test del espejo, antes de usarlos.** Extender `concrete-status-tokens.test.ts` con: + `--st-provisional` resuelto === `0xa066d3` de `rebar-scene.ts`, comparado como valor. Si + alguien cambia uno de los dos, falla y dice cuál. +3. **`ProvisionalBanner`** — el uso canónico, y hormigón puro. 4 → 0. Baja el techo. +4. **`FloorFamilyStateCard`** — `provisional` de `--st-warn` a `--st-provisional-text`. Cierra + la discrepancia. Tocar acá los siete estados exige re-verificar + `floor-family-states.spec.ts`, que ya mide el par glifo + palabra en tres idiomas. +5. **`VerificationDetail`** — 3 → 0 o casi. +6. **`OutcomeBadge` + `DesignToolbar`** — **coordinado con M1.** Último, porque es el único paso + que las dos ramas ven. + +**Tests que tiene que traer cada paso**, con la estrategia que H1 ya aplicó cuatro veces: + +- **Techo por archivo** en `concrete-design-raw-colours.test.ts`: baja, nunca sube; un archivo + ausente del mapa tiene techo cero. +- **Contraste calculado** desde `tokens.css`, no a ojo — la tabla de §2 es la salida de ese test, + no una nota al pie. Y medido sobre el **composite** del rgba sobre el fondo real, porque el + contraste de un rgba contra nada no significa nada. +- **Token resuelto por el navegador** contra el color resuelto del elemento, más la **negativa** + contra el literal viejo: sobre fondo oscuro, `#5c1a1a` y un pozo `--st-surface-3` se parecen lo + suficiente como para que un screenshot acepte cualquiera de los dos. +- **1280×720** y **en/es/pt** donde el texto pueda cambiar el layout. +- **Cobertura declarada, no implícita.** Si el fixture no produce el estado, decirlo en el test + —H1 tuvo dos casos así, `advisory` en el mat de bases y la banda de conflicto— en vez de dejar + una aserción condicional que se lee como si hubiera medido. + +**Lo que ninguno de estos pasos debe hacer:** ampliar el vocabulario de estados. Estos tokens +existen para que los estados que ya hay dejen de mezclarse el color a mano. No habilitan un +`VERIFIED` nuevo ni un estado de aprobación. + +--- + +## 6. Lo que queda abierto y no propongo resolver acá + +- **`--st-warn` para dos estados distintos.** `RebarStatusPanel` distingue `refused` de + `designed-not-modelled` sólo por matiz, y los dos son "advertencia". Con `--st-warn` y + `--st-danger` como único vocabulario, tokenizarlos los fusionaría. Por eso los nueve literales + que quedan en ese archivo están congelados, no pendientes. Un `--st-warn-2` resolvería esto, + pero **no lo propongo**: seis matices de estado es más de lo que un lector distingue, y la + salida honesta es que la palabra ya lleva el estado y el matiz es soporte. +- **`blocking` vs `advisory` se distinguen sólo por color** en `FootingMatPhysicalPanel`, y ya era + así antes de tokenizarlo. Un glifo lo arreglaría; es cambio de contenido, no de token. +- **`#6fa8ff` sigue en `RebarWorkspace.svelte`** (borde del spinner) mientras los tres paneles + hijos pasaron a `--st-interactive`. `RebarWorkspace` está fuera de alcance. Nota medida: + `--st-interactive` sobre `--st-surface-3` da **4.36**, que pasa el 3:1 de WCAG 1.4.11 para un + borde y **no** el 4.5 para texto — por eso en los tres paneles va como `border-color` con + `--st-text` al lado, nunca como color de etiqueta. diff --git a/web/e2e/viewer-panel-tokens.spec.ts b/web/e2e/viewer-panel-tokens.spec.ts new file mode 100644 index 000000000..652d88c83 --- /dev/null +++ b/web/e2e/viewer-panel-tokens.spec.ts @@ -0,0 +1,401 @@ +/** + * The viewer's concrete panels paint from tokens, and the scene's colours stay the scene's. + * + * ── What only a browser can settle ───────────────────────────────── + * + * `concrete-status-tokens.test.ts` reads the source and computes the contrast arithmetic from + * `tokens.css`. Two things it cannot see: + * + * 1. **Whether the token resolves to what it says.** `.workspace` SHADOWS `--st-border` with + * `--st-hair-strong`, so a panel inside the overlay and the same panel outside it paint + * different values from one declaration. Only `getComputedStyle` inside the real cascade + * knows which. + * 2. **Whether a hover rule fires at all.** A `:hover` selector that never matches is invisible + * to a source assertion and to a screenshot. + * + * ── The route, and why it is the cheap one first ─────────────────── + * + * `RebarScenePanel` mounts twice: inside `RebarWorkspace` and inside `DocumentsSection`. The + * second is reachable without opening the WebGL workspace at all, which is where its one + * tokenised rule — the filled `.open` button — lives. That part runs in all three languages. The + * overlay panels need the scene built, so they run once, at the same width. + */ + +import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures'; +import type { Page } from '@playwright/test'; + +test.use({ viewport: { width: 1280, height: 720 } }); + +const resolve = (page: Page, colour: string) => + page.evaluate((c) => { + const el = document.createElement('span'); + el.style.color = c; + document.body.appendChild(el); + const out = getComputedStyle(el).color; + el.remove(); + return out; + }, colour); + +/** A token as the browser finally paints it, read from the element that USES it. */ +const tokenOn = (page: Page, testid: string, name: string) => + page.getByTestId(testid).evaluate( + (el, n) => getComputedStyle(el).getPropertyValue(n).trim(), name); + +const resolvedOn = async (page: Page, testid: string, name: string) => + resolve(page, await tokenOn(page, testid, name)); + +/** + * Reach the scene panel in Documents — no workspace, no WebGL. + * + * The assemblies poll is not optional: `doc-3d` is clickable before the detailing exists, and + * the panel then renders `rebar-empty` with no `.open` button in it, which would make every + * assertion below vacuous rather than failing. + */ +async function openScenePanel(page: Page) { + await loadModel(page, 'rc-design-qa-8'); + await designAll(page); + await page.getByTestId('detailing-disclosure').locator('> summary').click(); + const generate = page.getByTestId('cmd-generate-detailing'); + await expect(generate).toBeEnabled(); + await generate.click(); + await expect + .poll(() => page.evaluate(() => + (window.__stabileo as unknown as { detailingAssemblies(): unknown[] }) + .detailingAssemblies().length), { timeout: 60_000 }) + .toBeGreaterThan(0); + await openDocumentsStage(page); + await buildScene(page); + /* + * The overlay is CLOSED again on purpose. + * + * `doc-3d` does not merely reveal the Documents panel — it opens the workspace over it. The + * first version of this file then clicked `rebar-open-workspace` to "open" a workspace that + * was already open, and Playwright waited three minutes for a button sitting under the + * overlay to become actionable. Reading a computed style off a covered element works, which + * is why the five assertions that only measure passed and the three that clicked did not. + */ + await page.getByTestId('rebar-workspace-close').click(); + await expect(page.getByTestId('rebar-workspace')).toHaveCount(0); + await expect(page.getByTestId('rebar-open-workspace')).toBeVisible({ timeout: 60_000 }); +} + +/** Click through to the 3-D document and wait on the BUILD COUNTER, not on the paint. */ +async function buildScene(page: Page) { + const before = await page.evaluate(() => + (window.__stabileo as unknown as { rebarSceneBuilds(): number }).rebarSceneBuilds()); + await page.getByTestId('doc-3d').click(); + await expect(page.getByTestId('rebar-workspace')).toBeVisible(); + await expect + .poll(() => page.evaluate(() => + (window.__stabileo as unknown as { rebarSceneBuilds(): number }).rebarSceneBuilds()), + { timeout: 120_000 }) + .toBeGreaterThan(before); +} + +test.describe('@slow the scene panel in Documents', () => { + test.slow(); + + test('the open-workspace button is the blue fill, not the danger fill', + async ({ pro: page }) => { + await openScenePanel(page); + const btn = page.getByTestId('rebar-open-workspace'); + const bg = await btn.evaluate((el) => getComputedStyle(el).backgroundColor); + const fg = await btn.evaluate((el) => getComputedStyle(el).color); + + expect(bg, 'the fill is --st-blue') + .toBe(await resolvedOn(page, 'rebar-open-workspace', '--st-blue')); + expect(fg, 'the label is --st-text-on-accent') + .toBe(await resolvedOn(page, 'rebar-open-workspace', '--st-text-on-accent')); + + // The negative that matters: `--st-accent` is vermillion and is what this application + // fills its DESTRUCTIVE buttons with. "Open workspace" must not have joined them. + expect(bg, 'and not the accent/danger fill') + .not.toBe(await resolvedOn(page, 'rebar-open-workspace', '--st-accent')); + // Nor the literal it replaced, which is four steps away on two channels. + expect(bg).not.toBe(await resolve(page, '#2b6cb0')); + }); + + test('the state dots still paint exactly what Three.js paints', async ({ pro: page }) => { + await openScenePanel(page); + /* + * The mirror, measured on the rendered page rather than in the stylesheet. A token that + * resolved to a near-miss would satisfy the source test and fail here. + */ + const expected: Record = { + failed: '#e0444a', unsupported: '#b06ad6', refused: '#d4762a', + 'designed-not-modelled': '#d9c04a', 'not-evaluated': '#8b93a3', modelled: '#4caf72', + }; + const seen: string[] = []; + for (const [state, hex] of Object.entries(expected)) { + const dot = page.locator(`.dot.${state}`).first(); + if (!(await dot.count())) continue; + seen.push(state); + expect(await dot.evaluate((el) => getComputedStyle(el).backgroundColor), state) + .toBe(await resolve(page, hex)); + } + expect(seen.length, 'at least one state row is on screen').toBeGreaterThan(0); + test.info().annotations.push( + { type: 'coverage', description: `dots measured: ${seen.join(', ') || 'none'}` }); + }); +}); + +for (const locale of ['en', 'es', 'pt'] as const) { + test.describe(`@slow the scene panel holds 1280×720 in ${locale}`, () => { + test.slow(); + test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } }); + + test('nothing in it overflows and the button keeps its fill', async ({ pro: page }) => { + await openScenePanel(page); + const panel = page.getByTestId('rebar-open-workspace') + .locator('xpath=ancestor::*[contains(@class,"scene")][1]'); + const target = (await panel.count()) ? panel : page.getByTestId('rebar-open-workspace'); + const box = await target.evaluate( + (el) => ({ scroll: el.scrollWidth, client: el.clientWidth })); + expect(box.scroll, `fits at 1280 in ${locale}`).toBeLessThanOrEqual(box.client + 1); + + // The label length changes per language; the fill must not. + const btn = page.getByTestId('rebar-open-workspace'); + expect(await btn.evaluate((el) => getComputedStyle(el).backgroundColor)) + .toBe(await resolvedOn(page, 'rebar-open-workspace', '--st-blue')); + // And the button did not grow out of the rail because a Portuguese verb is longer. + const w = (await btn.boundingBox())!.width; + expect(w, `the button stays a button in ${locale}`).toBeLessThan(420); + }); + }); +} + +test.describe('@slow inside the workspace overlay', () => { + test.slow(); + + /** + * The action buttons of the two panels this pass touched — scoped to the overlay. + * + * `.sel-actions button, .actions button` was the first version and it was wrong in the worst + * way: it resolved to `review-submit`, a "Record review" button belonging to another component + * entirely, sitting UNDER the workspace canvas. `hover()` reported the truth — the canvas + * intercepts pointer events — but the test that only READ a computed style measured that + * foreign button and passed. So the scope is the workspace, and the selection is made first, + * because `SelectionDetails` renders its actions only once something is selected. + */ + async function actionButton(page: Page) { + const ws = page.getByTestId('rebar-workspace'); + let btn = ws.locator('.sel-actions button:visible, .actions button:visible').first(); + if (!(await btn.count())) { + const row = ws.locator('[data-testid^="rebar-element-"]').first(); + if (await row.count()) { + await row.click(); + btn = ws.locator('.sel-actions button:visible, .actions button:visible').first(); + } + } + return btn; + } + + /** Re-open the overlay the way a user does after closing it, and wait on the build again. */ + async function openOverlay(page: Page) { + await openScenePanel(page); + const before = await page.evaluate(() => + (window.__stabileo as unknown as { rebarSceneBuilds(): number }).rebarSceneBuilds()); + await page.getByTestId('rebar-open-workspace').click(); + await expect(page.getByTestId('rebar-workspace')).toBeVisible(); + await expect + .poll(() => page.evaluate(() => + (window.__stabileo as unknown as { rebarSceneBuilds(): number }).rebarSceneBuilds()), + { timeout: 120_000 }) + .toBeGreaterThan(before); + } + + test('--st-border resolves to the STRONGER hairline in here, as the overlay intends', + async ({ pro: page }) => { + await openOverlay(page); + /* + * The one assertion no source test could make. `.workspace` declares + * `--st-border: var(--st-hair-strong)`, so the action buttons' 1px rule is the 0.38 + * hairline inside the overlay and would be the 0.22 one outside it. Both are correct; the + * point is that the shadow is live, which is why this pass left those fallbacks alone. + */ + const btn = await actionButton(page); + if (!(await btn.count())) { + test.info().annotations.push( + { type: 'note', description: 'no action button in the overlay — shadow unasserted' }); + return; + } + const border = await btn.evaluate((el) => getComputedStyle(el).borderTopColor); + const strong = await resolve(page, await page.getByTestId('rebar-workspace') + .evaluate((el) => getComputedStyle(el).getPropertyValue('--st-hair-strong').trim())); + expect(border, 'inside the overlay the rule is the strong hairline').toBe(strong); + }); + + test('the hover rule fires and pairs an interactive border with full-contrast text', + async ({ pro: page }) => { + await openOverlay(page); + /* + * `:visible`, and scrolled to, before hovering. + * + * The previous test reads a computed style and passes on an element the rail has scrolled + * out of view — `getComputedStyle` does not care. `hover()` does: it waits for + * actionability, and `.first()` over both selectors had been resolving to a button inside + * a collapsed inspector, so it waited the full three minutes for something that was never + * going to be hoverable. + */ + const btn = await actionButton(page); + if (!(await btn.count())) { + test.info().annotations.push( + { type: 'note', description: 'no visible action button — hover unasserted' }); + return; + } + // Named, so a future failure says which button was measured rather than "a button". + test.info().annotations.push({ + type: 'target', + description: `hovered ${await btn.getAttribute('data-testid') ?? '(untagged)'}`, + }); + await btn.scrollIntoViewIfNeeded(); + const beforeBorder = await btn.evaluate((el) => getComputedStyle(el).borderTopColor); + await btn.hover(); + const afterBorder = await btn.evaluate((el) => getComputedStyle(el).borderTopColor); + const afterText = await btn.evaluate((el) => getComputedStyle(el).color); + + // It fired. A `:hover` that never matches is invisible to a source assertion. + expect(afterBorder, 'the hover rule actually applies').not.toBe(beforeBorder); + const ws = page.getByTestId('rebar-workspace'); + const [interactive, text] = await Promise.all([ + ws.evaluate((el) => getComputedStyle(el).getPropertyValue('--st-interactive').trim()) + .then((v) => resolve(page, v)), + ws.evaluate((el) => getComputedStyle(el).getPropertyValue('--st-text').trim()) + .then((v) => resolve(page, v)), + ]); + expect(afterBorder).toBe(interactive); + expect(afterText, 'the label goes to full contrast, not to the blue') + .toBe(text); + expect(afterBorder, 'and not the literal it replaced') + .not.toBe(await resolve(page, '#6fa8ff')); + }); + + test('the torsion notice and the conflict band, when the model produces them', + async ({ pro: page }) => { + await openOverlay(page); + const ws = page.getByTestId('rebar-workspace'); + const tok = async (n: string) => + resolve(page, await ws.evaluate( + (el, name) => getComputedStyle(el).getPropertyValue(name).trim(), n)); + const [warn, text] = [await tok('--st-warn'), await tok('--st-text')]; + const seen: string[] = []; + + // `TorsionBanner` — the other half of the amber pair. + const banner = page.getByTestId('rebar-torsion-banner'); + if (await banner.count()) { + seen.push('torsion-banner'); + expect(await banner.evaluate((el) => getComputedStyle(el).color)).toBe(text); + expect(await banner.evaluate((el) => getComputedStyle(el).borderBottomColor)).toBe(warn); + expect(await banner.locator('strong').first() + .evaluate((el) => getComputedStyle(el).color)).toBe(warn); + // And it stopped borrowing the unreinforced orange. + expect(await banner.evaluate((el) => getComputedStyle(el).borderBottomColor)) + .not.toBe(await resolve(page, '#d4762a')); + } + + // `SelectionDetails` — the half that must match it. + const sel = page.getByTestId('rebar-sel-torsion'); + if (await sel.count()) { + seen.push('sel-torsion'); + expect(await sel.evaluate((el) => getComputedStyle(el).color)).toBe(text); + expect(await sel.locator('strong').first() + .evaluate((el) => getComputedStyle(el).color)).toBe(warn); + } + + // `ConflictInspector` — the band whose fill and rule stay the scene's. + const band = page.getByTestId('rebar-conflict-warning'); + if (await band.count()) { + seen.push('conflict-band'); + expect(await band.evaluate((el) => getComputedStyle(el).color)).toBe(text); + expect(await band.evaluate((el) => getComputedStyle(el).borderLeftColor)) + .toBe(await resolve(page, '#e0444a')); + } + + /* + * Stated, not implied. If this fixture shows none of the three, the amber pair and the + * conflict band are covered at source only, and saying so is the difference between a + * test that proves something and one that looks like it did. + */ + test.info().annotations.push({ + type: 'coverage', + description: seen.length + ? `measured in the browser: ${seen.join(', ')}` + : 'none of the three appeared on this fixture — source coverage only', + }); + }); +}); + +/** + * The amber pair, on the model that actually raises it. + * + * The first version of the test above reported "none of the three appeared on this fixture — + * source coverage only", which was true and useless: `rc-design-qa-8` designs without torsion, so + * `TorsionBanner` and `SelectionDetails`'s torsion line never rendered and the pair was verified + * nowhere but in the stylesheet. + * + * `rc-qa-diagnostic` raises the torsion banner — `rebar-toggles.spec.ts` relies on that same fact + * for its own worst-case rail test. So the pair is measured where it exists rather than asserted + * where it is convenient. + */ +test.describe('@slow the torsion amber, measured where the model raises it', () => { + test.slow(); + + test('the banner and the selection line resolve to the same two tokens', + async ({ pro: page }) => { + await loadModel(page, 'rc-qa-diagnostic'); + await designAll(page); + await page.getByTestId('detailing-disclosure').locator('> summary').click(); + const generate = page.getByTestId('cmd-generate-detailing'); + await expect(generate).toBeEnabled(); + await generate.click(); + await expect + .poll(() => page.evaluate(() => + (window.__stabileo as unknown as { detailingAssemblies(): unknown[] }) + .detailingAssemblies().length), { timeout: 60_000 }) + .toBeGreaterThan(0); + await openDocumentsStage(page); + await buildScene(page); + + const banner = page.getByTestId('rebar-torsion-banner'); + await expect(banner, 'this model must raise the torsion banner').toBeVisible(); + + const ws = page.getByTestId('rebar-workspace'); + const tok = async (n: string) => + resolve(page, await ws.evaluate( + (el, name) => getComputedStyle(el).getPropertyValue(name).trim(), n)); + const [warn, text] = [await tok('--st-warn'), await tok('--st-text')]; + + expect(await banner.evaluate((el) => getComputedStyle(el).color), + 'the banner body is full-contrast text').toBe(text); + expect(await banner.evaluate((el) => getComputedStyle(el).borderBottomColor), + 'and its rule is the warn token').toBe(warn); + expect(await banner.locator('strong').first() + .evaluate((el) => getComputedStyle(el).color), 'as is its emphasis').toBe(warn); + + // The negatives: the private pair, and the unreinforced orange it used to borrow. + for (const gone of ['#f2ddc6', '#ffbe7a', '#d4762a']) { + const lit = await resolve(page, gone); + expect(await banner.evaluate((el) => getComputedStyle(el).color), gone).not.toBe(lit); + expect(await banner.evaluate((el) => getComputedStyle(el).borderBottomColor), gone) + .not.toBe(lit); + } + + // And the other half of the pair, if a member carrying torsion can be selected. + const row = ws.locator('[data-testid^="rebar-element-"]').first(); + if (await row.count()) await row.click(); + const sel = page.getByTestId('rebar-sel-torsion'); + if (await sel.count()) { + expect(await sel.evaluate((el) => getComputedStyle(el).color), + 'SelectionDetails agrees with the banner').toBe(text); + expect(await sel.locator('strong').first() + .evaluate((el) => getComputedStyle(el).color)).toBe(warn); + test.info().annotations.push( + { type: 'coverage', description: 'banner AND selection line measured' }); + } else { + test.info().annotations.push({ + type: 'coverage', + description: 'banner measured; the selection line needs a torsioned member selected', + }); + } + }); +}); diff --git a/web/src/components/pro/design/ConflictInspector.svelte b/web/src/components/pro/design/ConflictInspector.svelte index c60ec0735..511e24c9d 100644 --- a/web/src/components/pro/design/ConflictInspector.svelte +++ b/web/src/components/pro/design/ConflictInspector.svelte @@ -93,11 +93,19 @@ .conflict { display: flex; flex-direction: column; gap: 0.35rem; } .head { margin: 0; display: flex; gap: 0.5rem; align-items: baseline; - font-size: 0.78rem; color: #ffb0b6; + font-size: 0.78rem; color: var(--st-text); } - /* Interpenetration and a spacing shortfall are different problems; the header says which - before the numbers do. */ - .head.overlap strong { color: #ff6b74; } + /* + Interpenetration and a spacing shortfall are different problems; the header says which + before the numbers do. + + Two pinks encoded that: `#ffb0b6` for every header and a brighter `#ff6b74` on the strong + when the class is `overlap`. The DIFFERENCE is what carries meaning, so the base goes to + `--st-text` and the emphasis to `--st-danger` — the alternative, base `--st-danger` with + nothing stronger left for overlap, would have merged the two. The card still reads as an + error from the band below it, whose rule is the conflict red. + */ + .head.overlap strong { color: var(--st-danger); } .head span { color: var(--text-muted, #8b93a3); font-size: 0.7rem; } dl { display: grid; grid-template-columns: auto 1fr; gap: 0.1rem 0.5rem; @@ -107,13 +115,17 @@ dd { margin: 0; font-variant-numeric: tabular-nums; } .warn { margin: 0.2rem 0 0; padding: 0.3rem 0.4rem; + /* Fill and rule frozen: `#e0444a` is `conflicted: 0xe0444a` in the 3-D scene and the + 0.14 fill is that same hue, so they move together or not at all. The SENTENCE takes + `--st-text`, which is the trade `FootingMatPhysicalPanel` measured — a status hue as + body text costs more contrast than it buys. */ background: rgba(224, 68, 74, 0.14); border-left: 2px solid #e0444a; - color: #ffd0d3; font-size: 0.72rem; line-height: 1.35; + color: var(--st-text); font-size: 0.72rem; line-height: 1.35; } .actions { display: flex; gap: 0.35rem; flex-wrap: wrap; } .actions button { background: none; border: 1px solid var(--st-border, #2c3444); border-radius: 4px; color: inherit; font-size: 0.72rem; padding: 0.2rem 0.45rem; cursor: pointer; } - .actions button:hover { border-color: #6fa8ff; color: #d7dce6; } + .actions button:hover { border-color: var(--st-interactive); color: var(--st-text); } diff --git a/web/src/components/pro/design/RebarScenePanel.svelte b/web/src/components/pro/design/RebarScenePanel.svelte index 95539c8b3..4165105e4 100644 --- a/web/src/components/pro/design/RebarScenePanel.svelte +++ b/web/src/components/pro/design/RebarScenePanel.svelte @@ -205,12 +205,29 @@ .sub, .hint, .note, .empty { margin: 0; font-size: 0.78rem; color: var(--text-muted, #8b93a3); } + /* + `--st-blue` and not `--st-accent`, deliberately. + `--st-accent` is documented as "primary action, brand" and this is one — but the + application also fills its DESTRUCTIVE buttons with it (`ProReportDialog`'s + `.rpt-btn-danger`, `ProAutoLoadsDialog`'s `.al-error`), so a vermillion "open workspace" + would read as a warning. `--st-blue` is the token whose value this literal already was + (#2c6cb4 against #2b6cb0), and `--st-text-on-accent` is exactly the `#fff` it replaces. + */ .open { align-self: flex-start; font-size: 0.82rem; padding: 0.35rem 0.75rem; cursor: pointer; - background: #2b6cb0; color: #fff; border: none; border-radius: 4px; + background: var(--st-blue); color: var(--st-text-on-accent); + border: none; border-radius: 4px; } .summary { margin: 0; font-size: 0.82rem; } + /* + Frozen, with the eight below it. `.warn` names the CONFLICTED bar count and `#e0444a` is + `conflicted: 0xe0444a` in `three/rebar-scene.ts`; `.unreinforced`'s rule is + `unreinforced: 0xd4762a`. A material cannot read a custom property, so a token here would + let the words and the picture drift — and `--st-danger` is a different red (#e8705f), + which would put two reds for one meaning in one panel. See + `concrete-status-tokens.test.ts`, which asserts the mirror in both directions. + */ .warn { color: #e0444a; } .states { list-style: none; margin: 0; padding: 0; font-size: 0.76rem; } .states li { display: flex; align-items: center; gap: 0.35rem; padding: 0.05rem 0; } diff --git a/web/src/components/pro/design/SelectionDetails.svelte b/web/src/components/pro/design/SelectionDetails.svelte index ca8501060..4edf95b4f 100644 --- a/web/src/components/pro/design/SelectionDetails.svelte +++ b/web/src/components/pro/design/SelectionDetails.svelte @@ -125,9 +125,11 @@ dd { margin: 0; } .hint { margin: 0; font-size: 0.72rem; color: var(--text-muted, #8b93a3); } .sel-status { margin: 0.3rem 0 0; font-size: 0.74rem; } - /* The same amber the workspace banner uses. One colour, one meaning. */ - .sel-torsion { margin: 0.25rem 0 0; font-size: 0.74rem; color: #f2ddc6; } - .sel-torsion strong { color: #ffbe7a; } + /* The same amber the workspace banner uses. One colour, one meaning — so this pair and + `TorsionBanner`'s moved to the tokens together. Tokenising one of the two would have + broken the equality this comment exists to state. */ + .sel-torsion { margin: 0.25rem 0 0; font-size: 0.74rem; color: var(--st-text); } + .sel-torsion strong { color: var(--st-warn); } .lim { color: var(--text-muted, #8b93a3); } .sel-reason { margin: 0.15rem 0 0; font-size: 0.7rem; line-height: 1.35; @@ -138,5 +140,5 @@ background: none; border: 1px solid var(--st-border, #2c3444); border-radius: 4px; color: inherit; font-size: 0.72rem; padding: 0.2rem 0.45rem; cursor: pointer; } - .sel-actions button:hover { border-color: #6fa8ff; color: #d7dce6; } + .sel-actions button:hover { border-color: var(--st-interactive); color: var(--st-text); } diff --git a/web/src/components/pro/design/TorsionBanner.svelte b/web/src/components/pro/design/TorsionBanner.svelte index dd155540e..793bd9e1d 100644 --- a/web/src/components/pro/design/TorsionBanner.svelte +++ b/web/src/components/pro/design/TorsionBanner.svelte @@ -43,11 +43,13 @@ padding: 0.4rem 0.75rem; /* Amber, which is neither the violet of a proposal nor the red of a conflict: this is an unverified action, not an unbuildable bar and not a clash. One colour, one meaning. */ - background: rgba(212, 118, 42, 0.16); - border-bottom: 1px solid #d4762a; - color: #f2ddc6; + background: var(--st-surface-3); + border-bottom: 1px solid var(--st-warn); + color: var(--st-text); font-size: 0.76rem; line-height: 1.4; } - .torsion-banner strong { color: #ffbe7a; letter-spacing: 0.02em; } + /* `--st-warn`, not the `#d4762a` the scene paints unreinforced bars with: a torsion + advisory and an unreinforced bar are unrelated states that happened to share an orange. */ + .torsion-banner strong { color: var(--st-warn); letter-spacing: 0.02em; } diff --git a/web/src/lib/__tests__/concrete-design-raw-colours.test.ts b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts index 86803d28e..81f4e9271 100644 --- a/web/src/lib/__tests__/concrete-design-raw-colours.test.ts +++ b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts @@ -61,20 +61,25 @@ function rawColours(source: string): number { const CEILING: Record = { // ── concrete-only ── 'BatchEditDialog.svelte': 3, - 'ConflictInspector.svelte': 10, + // 5 left: two `--text-muted` and one `--st-border` fallback the overlay contract requires, + // plus `#e0444a` and its 0.14 fill — `conflicted: 0xe0444a` in `three/rebar-scene.ts`. + 'ConflictInspector.svelte': 5, 'DesignFamilyPanel.svelte': 2, 'FloorFamiliesPanel.svelte': 1, 'FootingCadHandoffPanel.svelte': 3, 'FootingMatPanel.svelte': 3, 'ProvisionalBanner.svelte': 4, - 'RebarScenePanel.svelte': 13, + // 11 left: three `--text-muted` fallbacks that are LIVE — this panel also mounts in + // `DocumentsSection`, outside `.workspace`, where the alias does not exist — and eight values + // the 3-D scene owns (six state dots, the conflicted count, the unreinforced rule). + 'RebarScenePanel.svelte': 11, // 9 left, all of them the state palette: four mirrored by value in // `three/rebar-scene.ts` and three with no token to go to. See // `concrete-status-tokens.test.ts`, which asserts the contract in both directions. 'RebarStatusPanel.svelte': 9, 'SectionAdviceDialog.svelte': 2, - 'SelectionDetails.svelte': 9, - 'TorsionBanner.svelte': 4, + // 5 left, all of them required fallbacks: four `--text-muted` and one `--st-border`. + 'SelectionDetails.svelte': 5, 'VerificationDetail.svelte': 3, // ── shared PRO surface: coordinate before lowering ── 'DesignToolbar.svelte': 12, @@ -84,7 +89,9 @@ const CEILING: Record = { 'RebarWorkspace.svelte': 6, }; -const TOTAL_CEILING = 102; // was 132: −20 FootingMatPhysicalPanel, −10 RebarStatusPanel +// 132 at the start of this work. −20 FootingMatPhysicalPanel, −10 RebarStatusPanel, +// −2 RebarScenePanel, −5 ConflictInspector, −4 SelectionDetails, −4 TorsionBanner. +const TOTAL_CEILING = 87; const files = () => readdirSync(DIR).filter((f) => f.endsWith('.svelte')); @@ -125,7 +132,9 @@ describe('the raw-colour debt does not grow', () => { * line in this list rather than a deletion nobody reads. */ describe('the files already at zero stay there', () => { - const AT_ZERO = ['DetailingWorkflow.svelte', 'FootingMatPhysicalPanel.svelte']; + const AT_ZERO = [ + 'DetailingWorkflow.svelte', 'FootingMatPhysicalPanel.svelte', 'TorsionBanner.svelte', + ]; it('each of them still has none', () => { for (const f of AT_ZERO) { diff --git a/web/src/lib/__tests__/concrete-status-tokens.test.ts b/web/src/lib/__tests__/concrete-status-tokens.test.ts index 02b09e82d..aab28376a 100644 --- a/web/src/lib/__tests__/concrete-status-tokens.test.ts +++ b/web/src/lib/__tests__/concrete-status-tokens.test.ts @@ -47,7 +47,11 @@ function resolveToken(name: string, depth = 0): string { /** sRGB → relative luminance, WCAG 2.1 §1.4.3. */ function luminance(hex: string): number { - const h = hex.replace('#', ''); + let h = hex.replace('#', ''); + // `--st-text-on-accent` is `#fff`. Slicing a shorthand two characters at a time yields NaN, + // and `NaN >= 4.5` is false, so the assertion failed for the right reason and the wrong cause. + if (h.length === 3) h = h.split('').map((c) => c + c).join(''); + expect(h, `${hex} must be a 6-digit hex`).toHaveLength(6); const ch = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); const lin = ch.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; @@ -256,3 +260,178 @@ describe('the rebar state palette is a contract with the 3-D scene, not debt', ( expect(p).toMatch(/Three\.js owns them|mirrored BY VALUE/); }); }); + +/** + * The same mirror, in the two other panels that hold it. + * + * `RebarStatusPanel` was not the only surface naming the scene's colours by value. + * `RebarScenePanel` repeats the state dots plus the conflicted count and the unreinforced rule, + * and `ConflictInspector` fills its warning band with the conflicted hue. Asserted here because + * the last pass proved the contract for one file and the ceiling map alone cannot say WHICH of a + * file's remaining literals are the contract and which are simply left. + */ +describe('the scene mirror holds across every panel that repeats it', () => { + const scene = () => readFileSync( + new URL('../three/rebar-scene.ts', import.meta.url).pathname, 'utf8'); + + it('RebarScenePanel keeps the six dots, the conflicted count and the unreinforced rule', () => { + const p = read('RebarScenePanel.svelte'); + for (const [cls, hex] of [ + ['.dot.failed', 'e0444a'], ['.dot.refused', 'd4762a'], + ['.dot.unsupported', 'b06ad6'], ['.dot.designed-not-modelled', 'd9c04a'], + ['.dot.not-evaluated', '8b93a3'], ['.dot.modelled', '4caf72'], + ] as const) { + expect(p, cls).toContain(`${cls} { background: #${hex}; }`); + } + // `.warn` is the CONFLICTED bar count, so it is the conflicted hue and not `--st-danger`, + // which is a different red and would put two reds for one meaning in one panel. + expect(p).toContain('.warn { color: #e0444a; }'); + expect(p).toMatch(/\.unreinforced \{\s*border-left: 3px solid #d4762a;/); + expect(scene()).toMatch(/conflicted:\s*0xe0444a/); + expect(scene()).toMatch(/unreinforced:\s*0xd4762a/); + }); + + it('ConflictInspector keeps the band fill and rule, and only those', () => { + const css = read('ConflictInspector.svelte').replace(/\/\*[\s\S]*?\*\//g, ''); + expect(css).toContain('border-left: 2px solid #e0444a'); + // The 0.14 fill is that same hue written as an rgba, so the two move together or not at all. + expect(css).toContain('rgba(224, 68, 74, 0.14)'); + // And the pinks that were NOT the scene's are gone. + for (const gone of ['#ffb0b6', '#ff6b74', '#ffd0d3']) { + expect(css, `${gone} was a private pink`).not.toContain(gone); + } + }); + + it('the two-level conflict header still has two levels', () => { + // The whole point of the base going to `--st-text` rather than to `--st-danger`: had both + // taken a status hue, interpenetration and a spacing shortfall would look the same. + const css = read('ConflictInspector.svelte').replace(/\/\*[\s\S]*?\*\//g, ''); + const base = css.match(/\.head \{([^}]*)\}/); + expect(base![1]).toContain('var(--st-text)'); + expect(css).toMatch(/\.head\.overlap strong \{ color: var\(--st-danger\); \}/); + }); +}); + +/** + * The amber pair moved as a pair. + * + * `SelectionDetails` carried `#f2ddc6`/`#ffbe7a` under the comment "The same amber the workspace + * banner uses. One colour, one meaning", and `TorsionBanner` carried the identical two. Doing one + * of them would have broken exactly the equality that comment asserts, so this holds them equal + * through the tokens instead of through two literals that happen to match. + */ +describe('the torsion amber is one colour with one meaning', () => { + const FILES = ['SelectionDetails.svelte', 'TorsionBanner.svelte']; + + it('neither file carries the old pair', () => { + for (const f of FILES) { + const css = read(f).replace(/\/\*[\s\S]*?\*\//g, ''); + for (const gone of ['#f2ddc6', '#ffbe7a']) { + expect(css, `${f}: ${gone}`).not.toContain(gone); + } + } + }); + + it('and both reach for the same two tokens', () => { + for (const f of FILES) { + const css = read(f).replace(/\/\*[\s\S]*?\*\//g, ''); + expect(css, `${f} body text`).toMatch(/color: var\(--st-text\)/); + expect(css, `${f} emphasis`).toMatch(/strong \{ color: var\(--st-warn\)/); + } + }); + + it('the banner no longer borrows the unreinforced orange for a torsion notice', () => { + // `#d4762a` is `unreinforced: 0xd4762a`. A torsion advisory and an unreinforced bar are + // unrelated states that happened to share an orange; only one of them is a scene contract. + const css = read('TorsionBanner.svelte').replace(/\/\*[\s\S]*?\*\//g, ''); + expect(css).not.toContain('#d4762a'); + expect(css).not.toContain('rgba(212, 118, 42'); + expect(css).toMatch(/border-bottom: 1px solid var\(--st-warn\)/); + }); +}); + +/** Every role this pass introduced clears AA where it carries text. */ +describe('the new roles are legible', () => { + it('every role that carries TEXT clears 4.5:1 on the ground it sits on', () => { + const cases: Array<[string, string, string]> = [ + // `.link` and the torsion emphasis sit on the panel, which is `--st-surface` — inside the + // overlay `--panel` aliases to exactly that. Not `--st-surface-3`, which is the hover + // well and a different measurement; see the next test. + ['--st-interactive', '--st-surface', 'a link'], + ['--st-warn', '--st-surface', 'the torsion emphasis'], + ['--st-danger', '--st-surface', 'the overlap emphasis'], + ['--st-text-on-accent', '--st-blue', 'the filled open-workspace button'], + ]; + for (const [fg, bg, what] of cases) { + expect(contrast(resolveToken(fg), resolveToken(bg)), `${what}: ${fg} on ${bg}`) + .toBeGreaterThanOrEqual(4.5); + } + }); + + it('and the hover border is measured as a border, which is a different bar', () => { + /** + * `--st-interactive` on `--st-surface-3` is **4.36:1** — under AA for text, over the 3:1 + * WCAG 2.1 §1.4.11 asks of a non-text boundary. The three panels use it only as + * `border-color`, with `--st-text` beside it for the words, so this is the right threshold + * and not a lowered one. + * + * Recorded because the literal it replaces, `#6fa8ff`, measured 6.17:1. Real headroom was + * given up for system membership, which is a trade worth being able to see rather than + * discover. + */ + const asBorder = contrast(resolveToken('--st-interactive'), resolveToken('--st-surface-3')); + expect(asBorder, 'clears the non-text bar').toBeGreaterThanOrEqual(3); + expect(asBorder, 'and does NOT clear the text bar, so it must stay a border') + .toBeLessThan(4.5); + + // So every hover rule that takes it pairs it with `--st-text` for the label. + for (const f of ['ConflictInspector.svelte', 'SelectionDetails.svelte']) { + const css = read(f).replace(/\/\*[\s\S]*?\*\//g, ''); + expect(css, `${f} hover`).toMatch( + /button:hover \{ border-color: var\(--st-interactive\); color: var\(--st-text\); \}/); + } + }); + + it('the filled button did not silently change hue', () => { + // `--st-accent` is the documented "primary action" token AND the fill the application uses + // for destructive buttons. `--st-blue` is what the literal already was. + expect(resolveToken('--st-blue')).toBe('#2c6cb4'); + const css = read('RebarScenePanel.svelte').replace(/\/\*[\s\S]*?\*\//g, ''); + expect(css).toMatch(/\.open \{[^}]*background: var\(--st-blue\)/); + expect(css, 'and did not take the danger fill').not.toMatch( + /\.open \{[^}]*var\(--st-accent\)/); + }); +}); + +/** + * The fallbacks that must survive, listed rather than inferred. + * + * `RebarScenePanel` mounts in `RebarWorkspace` AND in `DocumentsSection`. Outside the overlay + * `--text-muted` is not defined at all, so its `#8b93a3` is the value that paints — the fallback + * is load-bearing, not residue. That is why this pass left every one of them alone. + */ +describe('the overlay fallbacks are intact', () => { + const WITH_FALLBACKS: Record = { + 'RebarScenePanel.svelte': 3, + 'ConflictInspector.svelte': 3, + 'SelectionDetails.svelte': 5, + }; + + it('each panel keeps exactly the fallbacks it had', () => { + for (const [f, n] of Object.entries(WITH_FALLBACKS)) { + const css = read(f).replace(/\/\*[\s\S]*?\*\//g, ''); + const calls = css.match(/var\(--(?:text|text-muted|st-border|panel)\b[^)]*\)/g) ?? []; + expect(calls.length, `${f} fallback call count`).toBe(n); + for (const c of calls) { + expect(c, `${f}: ${c} must keep its fallback`).toMatch(/,\s*[^)]+\)$/); + } + } + }); + + it('and RebarScenePanel really does render outside the overlay', () => { + // The premise of the line above. If this stops being true the fallbacks become dead weight + // and the panel can join the others on `--st-text-2`. + const docs = read('DocumentsSection.svelte'); + expect(docs, 'DocumentsSection mounts the scene panel').toContain('RebarScenePanel'); + }); +}); From dfa20d8bf4ad8b6190c53ef92c09c2f086567950 Mon Sep 17 00:00:00 2001 From: Bauti Date: Fri, 21 Aug 2026 10:46:18 -0300 Subject: [PATCH 06/36] feat(tokens): the shared status-surface contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tokens and the gate that keeps them honest. **No consumer is touched by this commit** — that is the point: M1 can verify the contract before anything depends on it. --st-danger-bg: rgba(192, 57, 43, 0.14) --st-warn-bg: rgba(184, 134, 11, 0.14) --st-provisional: #a066d3 --st-provisional-text: #d8b4ff --st-provisional-bg: rgba(160, 102, 211, 0.16) ── Verified, not copied ─────────────────────────────────────────── M1's measured values, checked before being written. All **36** combinations (3 surfaces × 4 grounds × 3 text colours) clear 4.5:1. The tightest is `--st-danger` on `--st-danger-bg` composited over `--st-surface-3`: **4.54**, which passes with 0.04 to spare. That is not a margin, so the test pins it — anything that darkens `--st-surface-3` or lightens `--st-red` breaks it and says which. Two deltas from H1's own earlier proposal, both M1's and both better: `--st-warn-bg` at **0.14**, not 0.16. One alpha for both surfaces rather than two, and the worst case only moves to 4.76. `--st-provisional-text` **#d8b4ff**, not #c08ae6. 9.58 on `--st-surface` instead of 6.46 — and it is the value `OutcomeBadge`'s `.badge-provisional` already ships, so adopting the token changes no pixel there. Same for `-bg` at 0.16: `ProvisionalBanner` and that badge were already right, and matching them means the migration is a no-op on the two surfaces that had it. ── A correction to the brief: where 3:1 applies ─────────────────── The ask was "borders and non-text elements ≥ 3:1". For TRAZOS that holds — the minimum of the set is `--st-provisional` at 3.77 on `--st-surface-3`. Applied to the tint itself against the ground beneath it, the answer is **1.09–1.21**, and no alpha fixes it: a tint that reached 3:1 against its own ground would not be a tint. §1.4.11 is about the boundary of a control and about meaningful graphics — both covered — not about a decorative fill behind text whose contrast is measured separately. So the test asserts the three tints stay BELOW 1.5, which stops someone "fixing" them by darkening them. ── Rule 4, and the metric that had to be thrown away ────────────── "No component re-mixes a tinted surface that now has a token." The first version compared each tint's composite against each token's composite and flagged anything under a distance threshold. It cannot work: `rgba(238,34,34,.16)` — a red that IS `--st-danger-bg` — sits 11.4 away, and `rgba(255,255,255,.08)`, plain white with no status hue at all, sits 12.3. No threshold separates them. HUE does, with a gap nothing lands in: every true equivalent is within **18.4°** of a token hue and the nearest false positive is **54.4°**. Achromatic fills — scrims, white hovers, slate wells — drop out on saturation before hue is considered, because the hue of a grey is noise. The gap itself is asserted, so if a future colour lands inside it the rule stops separating and says so rather than being trusted. Fourteen exemptions, each with its reason, in two kinds that must not be confused: **contract** (permanent) — `RebarStatusPanel`'s `rgba(255,212,0,.16)` is `selected: 0xffd400` and `ConflictInspector`'s `rgba(224,68,74,.14)` is the fill of `conflicted: 0xe0444a`. The list and the viewport have to agree. **pending** (debt with an owner) — the ten in `OutcomeBadge`, `ProvisionalBanner`, `DesignToolbar` and `VerificationDetail` that commits 2 and 3 remove. A second assertion fails on an exemption for a literal that is gone, so the list shrinks instead of rotting. **not a band** — the diagnostics command's own fill and its hover level, and an inline dialog note. Affordances, not status surfaces. ── Rule 3, by value ─────────────────────────────────────────────── `--st-provisional` is held equal to `three/rebar-scene.ts`'s `0xa066d3`, both sides parsed to a triplet rather than string-matched: `0xA066D3` and `#a066d3` are one colour written two ways, and a text comparison would fail on a case change and pass on `#a166d3`. The dots stay literal even though the token now exists. A `var()` in CSS and an `0x` in a material can drift apart in silence; a duplicated literal with a test comparing them cannot. ── One test failed, exactly as designed ─────────────────────────── `concrete-status-tokens.test.ts` carried `expect(TOKENS, 'no violet exists yet').not.toMatch(/--st-(violet|purple|provisional):/)` — written last week so that the day a violet arrived it would fail and point at the work. It did. Its premise is restated, not relaxed. `unsupported`, `designed-not-modelled` and `not-evaluated` still have nowhere to go: the vocabulary is five wide now and all five are spoken for, and `--st-provisional` names a DIFFERENT violet — `#a066d3` for provisional, not the `#b06ad6` that panel paints `unsupported` with. Two violets, two states. Gates: unit 374 files / 7001 tests · build tests 14 · production build 17.0 s · typecheck 479 against baseline 479 · shared-status-tokens 25/25 · detailing-sheet-fieldset + footing-status-tokens 13/13 at 1280×720 (unchanged, as expected — no consumer moved) · served at 127.0.0.1:4003. --- .../h1-shared-status-tokens-proposal.md | 48 ++- .../__tests__/concrete-status-tokens.test.ts | 36 +- .../__tests__/shared-status-tokens.test.ts | 352 ++++++++++++++++++ web/src/styles/tokens.css | 51 +++ 4 files changed, 471 insertions(+), 16 deletions(-) create mode 100644 web/src/lib/__tests__/shared-status-tokens.test.ts diff --git a/docs/handoffs/h1-shared-status-tokens-proposal.md b/docs/handoffs/h1-shared-status-tokens-proposal.md index 4f9c233a8..0ec292392 100644 --- a/docs/handoffs/h1-shared-status-tokens-proposal.md +++ b/docs/handoffs/h1-shared-status-tokens-proposal.md @@ -1,9 +1,51 @@ # Propuesta para M1 — tres tokens de estado que faltan en `tokens.css` **Origen:** H1 (`feat/pro-concrete-h1`), tokenización de la cubeta 1 de hormigón. -**Estado:** propuesta. **Nada de esto está implementado.** `tokens.css`, `DesignToolbar.svelte`, -`OutcomeBadge.svelte`, `SteelStatusBadge.svelte` y `ProvisionalBanner.svelte` están sin tocar. -**Decisión pendiente:** de Bauti y Diego. `tokens.css` es superficie compartida H1/M1. +**Estado: el contrato está IMPLEMENTADO.** H1 es el dueño único de la implementación física. +M1 no debe editar `tokens.css` ni los consumidores mientras el bloque esté en curso. + +--- + +## 0. Estado de implementación + +| | Commit | Qué | +|---|---|---| +| ✅ | **1 — contrato** | los cinco tokens en `tokens.css` + `shared-status-tokens.test.ts` (25 aserciones). **Ningún consumidor tocado.** Esto es lo que M1 tiene que verificar. | +| ⏳ | 2 — consumidores | `FloorFamilyStateCard`, `ProvisionalBanner`, `OutcomeBadge`, `DesignToolbar` | +| ⏳ | 3 — cubeta 1 restante | los 14 literales de hormigón puro | + +### Los valores finales, y los dos deltas contra §2 + +Se adoptaron **los valores medidos por M1**, con dos diferencias respecto de lo que este +documento proponía originalmente. Las dos son de M1 y las dos verifiqué antes de escribirlas: + +| Token | Valor final | Delta vs propuesta original | +|---|---|---| +| `--st-danger-bg` | `rgba(192, 57, 43, 0.14)` | igual | +| `--st-warn-bg` | `rgba(184, 134, 11, **0.14**)` | era 0.16. **Un solo alfa para las dos** superficies es más simple y el peor caso sigue en 4.76 (`--st-text-2` sobre `--st-surface-3`). | +| `--st-provisional` | `#a066d3` | igual | +| `--st-provisional-text` | **`#d8b4ff`** | era `#c08ae6`. Da **9.58** sobre `--st-surface` en vez de 6.46, y es **el valor que `OutcomeBadge.badge-provisional` ya usa**, así que adoptarlo no cambia un píxel ahí. Mejor elección que la mía. | +| `--st-provisional-bg` | `rgba(160, 102, 211, 0.16)` | igual | + +**Verificado, no copiado.** Las 36 combinaciones (3 superficies × 4 fondos × 3 colores de texto) +pasan ≥ 4.5:1. El peor caso es `--st-danger` sobre `--st-danger-bg` compuesto sobre +`--st-surface-3`: **4.54**, con 0.04 de margen. El test lo fija explícitamente para que un +retoque de `--st-surface-3` o de `--st-red` lo rompa y lo diga. + +### Una corrección al pedido: el umbral de 3:1 + +El pedido decía «bordes y elementos no textuales ≥ 3:1». Aplicado a los **trazos** —dots, +bordes, mallas— se cumple: el mínimo del conjunto es `--st-provisional` con 3.77 sobre +`--st-surface-3`. + +Aplicado al **tinte mismo** contra el fondo que tiene debajo, da **1.09–1.21**, y ningún alfa lo +arregla: un tinte que llegara a 3:1 contra su propio fondo dejaría de ser un tinte. WCAG 2.1 +§1.4.11 habla del *borde de un control* y de *gráficos con significado* —los dos cubiertos— no +del relleno decorativo que va detrás de un texto cuyo contraste ya se mide aparte. El test +**assert­a que los tres tintes están por debajo de 1.5**, para que nadie los "arregle" +oscureciéndolos. + +--- --- diff --git a/web/src/lib/__tests__/concrete-status-tokens.test.ts b/web/src/lib/__tests__/concrete-status-tokens.test.ts index aab28376a..a888d8d5a 100644 --- a/web/src/lib/__tests__/concrete-status-tokens.test.ts +++ b/web/src/lib/__tests__/concrete-status-tokens.test.ts @@ -233,22 +233,32 @@ describe('the rebar state palette is a contract with the 3-D scene, not debt', ( .not.toMatch(/\.element\.selected[^}]*var\(--st-selected\)/); }); - it('the three panel-only states have no token to go to, and that is why they stay', () => { + it('the three panel-only states still have no token to go to', () => { /** - * `unsupported`, `designed-not-modelled` and `not-evaluated` are not in the scene. They - * stay literal because `tokens.css` offers exactly two status hues, `--st-warn` and - * `--st-danger`, and `--st-danger` is already `failed`. Sending two of these to `--st-warn` - * would merge states the panel's own comment forbids merging: "One colour per state, and - * never two states sharing one." + * This assertion has already earned its keep: it used to read "no violet exists yet" and it + * FAILED the moment `--st-provisional` was added, which is exactly what it was written to do. + * So the premise is restated rather than relaxed. * - * This asserts the PREMISE, so the day a violet or a second amber is added to the token - * system this test fails and points at the work. + * `unsupported`, `designed-not-modelled` and `not-evaluated` are not in the scene and still + * have nowhere to go. The vocabulary is now five wide, and every one of the five is spoken + * for: `--st-danger` is `failed`, `--st-warn` and `--st-ok` are taken, `--st-info` is not a + * state here, and `--st-provisional` names a DIFFERENT violet — `#a066d3` for `provisional`, + * not the `#b06ad6` this panel paints `unsupported` with. Two violets, two states. */ - const statusHues = [...TOKENS.matchAll(/--st-(warn|danger|ok|info):/g)].map((m) => m[1]); - expect(new Set(statusHues), 'the status vocabulary is still four wide') - .toEqual(new Set(['warn', 'danger', 'ok', 'info'])); - expect(TOKENS, 'no violet exists yet').not.toMatch(/--st-(violet|purple|provisional):/); - // And the panel still writes them out, rather than having quietly picked a near-match. + const statusHues = [...TOKENS.matchAll(/--st-(warn|danger|ok|info|provisional):/g)] + .map((m) => m[1]); + expect(new Set(statusHues), 'the status vocabulary is now five wide') + .toEqual(new Set(['warn', 'danger', 'ok', 'info', 'provisional'])); + + // And the violet that DOES exist is not the one `unsupported` needs. + const provisional = TOKENS.match(/--st-provisional:\s*(#[0-9a-fA-F]{6})/); + expect(provisional, '--st-provisional must be defined').not.toBeNull(); + expect(provisional![1].toLowerCase(), 'the token is the scene provisional violet') + .toBe('#a066d3'); + expect(panel(), 'and unsupported keeps its own, which no token names') + .toContain('#b06ad6'); + + // The three still written out, rather than having quietly picked a near-match. for (const hex of ['#b06ad6', '#d9c04a', '#8b93a3']) { expect(panel()).toContain(hex); } diff --git a/web/src/lib/__tests__/shared-status-tokens.test.ts b/web/src/lib/__tests__/shared-status-tokens.test.ts new file mode 100644 index 000000000..a725fad71 --- /dev/null +++ b/web/src/lib/__tests__/shared-status-tokens.test.ts @@ -0,0 +1,352 @@ +/** + * The shared status-surface contract: five tokens, and the rules that keep them honest. + * + * ── What this is ─────────────────────────────────────────────────── + * + * `tokens.css` had four status hues, all of them for text and trazo, and no status SURFACE. So + * every component that needed a band mixed its own — `#5c1a1a`/`#7a5b00` in the footing mat, + * `rgba(255,102,0,.13)` in the toolbar, `rgba(221,170,0,.16)` in the outcome badge — and none of + * them was `--st-amber` or `--st-red`. Provisional had it worse: two surfaces named a violet by + * value while a third sent the same state to `--st-warn`, so one state had two visual meanings. + * + * H1 owns the physical implementation; M1 supplied the measured starting values. This file is the + * gate that makes the contract checkable rather than agreed. + * + * ── The four rules ───────────────────────────────────────────────── + * + * 1. text on a status surface ≥ 4.5:1, over EVERY ground the surface can sit on + * 2. a hue used as a dot, rule or border ≥ 3:1 (WCAG 2.1 §1.4.11) + * 3. `--st-provisional` equals the value Three.js paints, compared as a resolved colour + * 4. no component writes a tinted background in a hue that now has a token, unless the + * exemption is declared here with its reason + * + * Rule 1 is measured on the COMPOSITE. A `rgba(…, 0.14)` fill has no colour of its own — it is + * whatever it lands on — so contrast against the raw rgba would be arithmetic about nothing. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const TOKENS = readFileSync( + new URL('../../styles/tokens.css', import.meta.url).pathname, 'utf8'); +const DESIGN = new URL('../../components/pro/design', import.meta.url).pathname; +const read = (f: string) => readFileSync(join(DESIGN, f), 'utf8'); + +/** Follow a token through its `var()` aliases until a literal falls out. */ +function resolveToken(name: string, depth = 0): string { + expect(depth, `${name} does not resolve to a literal`).toBeLessThan(8); + const m = TOKENS.match(new RegExp(`${name}\\s*:\\s*([^;]+);`)); + expect(m, `${name} must be defined in tokens.css`).not.toBeNull(); + const value = m![1].replace(/\/\*[\s\S]*?\*\//g, '').trim(); + const alias = value.match(/^var\((--[a-z0-9-]+)\)$/); + return alias ? resolveToken(alias[1], depth + 1) : value; +} + +type RGB = [number, number, number]; + +function rgb(colour: string): RGB { + const hex = colour.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); + if (hex) { + let h = hex[1]; + if (h.length === 3) h = h.split('').map((c) => c + c).join(''); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as RGB; + } + const f = colour.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + expect(f, `cannot read ${colour}`).not.toBeNull(); + return [1, 2, 3].map((i) => Number(f![i])) as RGB; +} + +const alphaOf = (colour: string): number => { + const m = colour.match(/rgba\([^)]*,\s*([\d.]+)\s*\)/); + return m ? Number(m[1]) : 1; +}; + +/** Flatten a translucent colour onto an opaque one, as the compositor does. */ +const composite = (fg: string, bg: string): RGB => { + const a = alphaOf(fg); + const [f, b] = [rgb(fg), rgb(bg)]; + return f.map((v, i) => Math.round(v * a + b[i] * (1 - a))) as RGB; +}; + +const luminance = ([r, g, b]: RGB): number => + [r, g, b] + .map((c) => c / 255) + .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)) + .reduce((s, c, i) => s + [0.2126, 0.7152, 0.0722][i] * c, 0); + +const contrast = (a: RGB, b: RGB): number => { + const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); + return (x + 0.05) / (y + 0.05); +}; + +/** Every opaque ground a panel can sit on. A surface must work on all of them, not the best. */ +const GROUNDS = ['--st-bg', '--st-surface', '--st-surface-2', '--st-surface-3'] as const; + +/** Each status surface, with the text tone that belongs to it. */ +const SURFACES = [ + { bg: '--st-danger-bg', tone: '--st-danger' }, + { bg: '--st-warn-bg', tone: '--st-warn' }, + { bg: '--st-provisional-bg', tone: '--st-provisional-text' }, +] as const; + +describe('the five tokens exist and resolve to literals', () => { + it('each one is defined', () => { + for (const n of [ + '--st-danger-bg', '--st-warn-bg', + '--st-provisional', '--st-provisional-text', '--st-provisional-bg', + ]) { + expect(() => resolveToken(n), n).not.toThrow(); + expect(resolveToken(n), n).toMatch(/^(#[0-9a-fA-F]{3,6}|rgba?\()/); + } + }); + + it('the two surfaces are derived from the palette, not invented', () => { + // `--st-red` and `--st-amber` at `--st-vermillion-dim`'s alpha. Asserted so a later edit + // cannot quietly drift the surface off the hue its `-text` twin belongs to. + expect(rgb(resolveToken('--st-danger-bg'))).toEqual(rgb(resolveToken('--st-red'))); + expect(rgb(resolveToken('--st-warn-bg'))).toEqual(rgb(resolveToken('--st-amber'))); + expect(alphaOf(resolveToken('--st-danger-bg'))).toBe(0.14); + expect(alphaOf(resolveToken('--st-warn-bg'))).toBe(0.14); + }); + + it('and provisional-bg keeps the alpha the two correct surfaces already shipped', () => { + // 0.16, not 0.14: `ProvisionalBanner` and `OutcomeBadge`'s `.badge-provisional` were already + // right, and matching them means adopting the token changes no pixel there. + expect(alphaOf(resolveToken('--st-provisional-bg'))).toBe(0.16); + expect(rgb(resolveToken('--st-provisional-bg'))).toEqual(rgb(resolveToken('--st-provisional'))); + }); +}); + +describe('rule 1 — text on a status surface clears 4.5:1 on every ground', () => { + for (const { bg, tone } of SURFACES) { + for (const ground of GROUNDS) { + it(`${bg} over ${ground}`, () => { + const surface = composite(resolveToken(bg), resolveToken(ground)); + for (const fg of ['--st-text', '--st-text-2', tone]) { + expect(contrast(rgb(resolveToken(fg)), surface), `${fg} on ${bg} over ${ground}`) + .toBeGreaterThanOrEqual(4.5); + } + }); + } + } + + it('and the tightest of the thirty-six is recorded, so a drift is visible', () => { + /** + * `--st-danger` on `--st-danger-bg` over `--st-surface-3` is the worst case: **4.54**. It + * passes with 0.04 to spare, which is not a margin. Anything that darkens `--st-surface-3` + * or lightens `--st-red` breaks it, and this assertion is what will say so. + */ + const worst = contrast( + rgb(resolveToken('--st-danger')), + composite(resolveToken('--st-danger-bg'), resolveToken('--st-surface-3'))); + expect(worst).toBeGreaterThanOrEqual(4.5); + expect(worst, 'still the tightest pair in the set').toBeLessThan(4.7); + }); +}); + +describe('rule 2 — a hue used as a dot, rule or border clears 3:1', () => { + it('every status hue does, on every ground', () => { + for (const t of ['--st-danger', '--st-warn', '--st-ok', '--st-info', + '--st-provisional', '--st-interactive']) { + for (const g of GROUNDS) { + expect(contrast(rgb(resolveToken(t)), rgb(resolveToken(g))), `${t} on ${g}`) + .toBeGreaterThanOrEqual(3); + } + } + }); + + it('`--st-provisional` clears 3:1 and does NOT clear 4.5:1, which is why -text exists', () => { + /** + * The measurement behind the two-strength split, and the one qualification to the product + * decision to align provisional with the scene's violet. + * + * `#a066d3` is 4.30 on `--st-surface` and 3.77 on `--st-surface-3`. Correct for a dot, where + * area carries the meaning; wrong for a 0.7rem label. Recommending the flat value for every + * role would have shipped a legibility regression under the banner of consistency. + */ + const v = rgb(resolveToken('--st-provisional')); + const onSurface = contrast(v, rgb(resolveToken('--st-surface'))); + expect(onSurface, 'fine as a dot').toBeGreaterThanOrEqual(3); + expect(onSurface, 'not fine as small text').toBeLessThan(4.5); + // And the label variant is, comfortably, on the surface its own band composites to. + expect(contrast( + rgb(resolveToken('--st-provisional-text')), + composite(resolveToken('--st-provisional-bg'), resolveToken('--st-surface')))) + .toBeGreaterThan(7); + }); + + it('the 3:1 bar is NOT applied to the tint itself, and the reason is arithmetic', () => { + /** + * A 14 % fill against the ground it sits on measures about **1.1:1**. That is not a defect + * and no alpha fixes it: a tint that reached 3:1 against its own ground would not be a tint. + * §1.4.11 is about the boundary of a control and about meaningful graphics — the border and + * the dot, both covered above — while what a band must guarantee is the legibility of the + * text on it, which is rule 1. + * + * Asserted rather than commented, so nobody "fixes" the surfaces by darkening them. + */ + for (const { bg } of SURFACES) { + const ratio = contrast( + composite(resolveToken(bg), resolveToken('--st-surface')), + rgb(resolveToken('--st-surface'))); + expect(ratio, `${bg} is a tint, by construction`).toBeLessThan(1.5); + } + }); +}); + +describe('rule 3 — provisional equals what Three.js paints', () => { + const scene = () => readFileSync( + new URL('../three/rebar-scene.ts', import.meta.url).pathname, 'utf8'); + + it('the token and the material agree, compared as a colour', () => { + /** + * By VALUE, not by literal text. `0xa066d3`, `0xA066D3` and `#a066d3` are one colour written + * three ways, and a test that string-matched would fail on a case change and pass on + * `#a166d3`. So both sides are parsed to a triplet. + */ + const m = scene().match(/provisional:\s*0x([0-9a-fA-F]{6})/); + expect(m, 'rebar-scene.ts must declare a provisional colour').not.toBeNull(); + expect(rgb(resolveToken('--st-provisional')), 'token === scene') + .toEqual(rgb(`#${m![1]}`)); + }); + + it('and the dot stays literal, because a var() and an 0x can drift in silence', () => { + // The token exists and the panel still writes the hex. Deliberate: the mirror is only safe + // while something compares the two, and that something is the test above. + expect(read('RebarStatusPanel.svelte')).toContain('#a066d3'); + /* + * `RebarScenePanel` is NOT checked here, and the reason is worth writing down: it lists six + * states, not seven — failed, unsupported, refused, designed-not-modelled, not-evaluated, + * modelled — and provisional is not one of them. Asserting the violet there would have + * demanded a dot that does not exist. + */ + expect(read('RebarScenePanel.svelte'), 'six dots, and provisional is not among them') + .not.toContain('.dot.provisional'); + }); +}); + +/** + * Rule 4 — nobody re-mixes a surface that now has a token. + * + * ── Why hue, and not colour distance ─────────────────────────────── + * + * The first version of this compared each tint's composite against each token's composite and + * flagged anything closer than a threshold. It cannot work: `rgba(238,34,34,.16)` — a red that + * IS `--st-danger-bg` — sits 11.4 away, and `rgba(255,255,255,.08)` — plain white, no status hue + * at all — sits 12.3. No threshold separates them. + * + * Hue does, with a gap nothing lands in: every true equivalent is within **18.4°** of a token's + * hue and the nearest false positive is **54.4°** away. Achromatic fills — scrims, white hovers, + * slate wells — are excluded by saturation before hue is even considered, because the hue of a + * grey is noise. + */ +describe('rule 4 — no component re-mixes a tinted status surface', () => { + const TOKEN_HUES = [ + ['--st-danger-bg', 6], ['--st-warn-bg', 43], ['--st-provisional-bg', 272], + ] as const; + const HUE_TOLERANCE = 30; // true equivalents ≤ 18.4°, nearest false positive 54.4° + const CHROMA_FLOOR = 0.25; // below this it is a grey and has no status hue to match + + /** + * The declared exemptions. + * + * Two kinds, and the difference matters. A CONTRACT exemption is permanent: the value belongs + * to `three/rebar-scene.ts` and a token would let the picture and the words drift. A PENDING + * one is debt with an owner — it stays until the file's own migration, and the list shrinking + * is the record of that happening. + */ + const EXEMPT: Record = { + // ── contract: the 3-D scene owns these values ── + 'RebarStatusPanel.svelte|rgba(255,212,0,0.16)': + 'contract — `selected: 0xffd400`. The list and the viewport must agree on which member is selected.', + 'ConflictInspector.svelte|rgba(224,68,74,0.14)': + 'contract — the 0.14 fill of `conflicted: 0xe0444a`, which its own border also names.', + + // ── pending: has a token, not yet migrated ── + 'OutcomeBadge.svelte|rgba(160,102,211,0.16)': 'pending — provisional badge.', + 'OutcomeBadge.svelte|rgba(180,120,220,0.16)': 'pending — the second provisional violet.', + 'OutcomeBadge.svelte|rgba(238,34,34,0.16)': 'pending — fail badge.', + 'OutcomeBadge.svelte|rgba(221,170,0,0.16)': 'pending — warn badge.', + 'OutcomeBadge.svelte|rgba(255,102,0,0.16)': 'pending — SECTION_INADEQUATE badge.', + 'ProvisionalBanner.svelte|rgba(160,102,211,0.16)': 'pending — the canonical provisional use.', + 'DesignToolbar.svelte|rgba(238,34,34,0.14)': 'pending — `.banner-block`.', + 'DesignToolbar.svelte|rgba(255,102,0,0.13)': 'pending — `.banner-warn`, an orange that is not --st-warn.', + 'VerificationDetail.svelte|rgba(255,102,0,0.08)': 'pending — the advice band.', + 'VerificationDetail.svelte|rgba(180,120,220,0.1)': 'pending — provisional advice.', + + // ── out of scope: an affordance rather than a status band ── + 'DesignToolbar.svelte|rgba(217,164,65,0.12)': + 'not a band — the diagnostics command\'s own fill, with a 0.22 hover level above it.', + 'DesignToolbar.svelte|rgba(217,164,65,0.22)': 'not a band — the hover level of the above.', + 'BatchEditDialog.svelte|rgba(255,204,102,0.08)': + 'not a band — an inline note inside a dialog, bordered with --st-hair-strong.', + }; + + /** Every translucent background in the design surface, with its hue. */ + function tints() { + const out: Array<{ file: string; literal: string; hue: number; sat: number }> = []; + for (const f of readdirSync(DESIGN).filter((n) => n.endsWith('.svelte'))) { + const css = read(f).replace(/\/\*[\s\S]*?\*\//g, ''); + const re = /background(?:-color)?:\s*rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)/g; + for (const m of css.matchAll(re)) { + const [r, g, b, a] = [1, 2, 3, 4].map((i) => Number(m[i])); + if (a >= 0.95) continue; + const [mx, mn] = [Math.max(r, g, b), Math.min(r, g, b)]; + const l = (mx + mn) / 2 / 255; + const sat = mx === mn ? 0 : (mx - mn) / 255 / (1 - Math.abs(2 * l - 1)); + let hue = 0; + if (mx !== mn) { + const d = mx - mn; + hue = mx === r ? ((g - b) / d + (g < b ? 6 : 0)) : mx === g + ? (b - r) / d + 2 : (r - g) / d + 4; + hue *= 60; + } + out.push({ file: f, literal: `rgba(${r},${g},${b},${a})`, hue, sat }); + } + } + return out; + } + + it('every tint in a token hue is either migrated or declared', () => { + const undeclared: string[] = []; + for (const t of tints()) { + if (t.sat < CHROMA_FLOOR) continue; + const near = TOKEN_HUES.find(([, h]) => + Math.min(Math.abs(t.hue - h), 360 - Math.abs(t.hue - h)) <= HUE_TOLERANCE); + if (!near) continue; + const key = `${t.file}|${t.literal}`; + if (!(key in EXEMPT)) undeclared.push(`${key} → ${near[0]}`); + } + expect(undeclared, 'a tinted status surface with a token and no exemption').toEqual([]); + }); + + it('and the list carries no exemption for a literal that is gone', () => { + // The half that makes the list shrink instead of rot: once a file migrates, its entry has to + // come out, and this is what says so. + const present = new Set(tints().map((t) => `${t.file}|${t.literal}`)); + const stale = Object.keys(EXEMPT).filter((k) => !present.has(k)); + expect(stale, 'exemptions for literals no longer in the source').toEqual([]); + }); + + it('the contract exemptions are the two the scene owns, and no more', () => { + // A `pending` entry is debt. A `contract` entry is permanent, so the set of them is worth + // pinning: adding a third means someone decided a new value belongs to Three.js. + const contract = Object.entries(EXEMPT) + .filter(([, why]) => why.startsWith('contract')) + .map(([k]) => k.split('|')[0]) + .sort(); + expect(contract).toEqual(['ConflictInspector.svelte', 'RebarStatusPanel.svelte']); + }); + + it('the hue gap the rule depends on is real, not assumed', () => { + // If a future colour lands between the tolerance and the nearest false positive, this rule + // stops separating and someone has to think again rather than trust it. + const hues = tints().filter((t) => t.sat >= CHROMA_FLOOR).map((t) => Math.min( + ...TOKEN_HUES.map(([, h]) => Math.min(Math.abs(t.hue - h), 360 - Math.abs(t.hue - h))))); + const inside = hues.filter((d) => d <= HUE_TOLERANCE); + const outside = hues.filter((d) => d > HUE_TOLERANCE); + expect(Math.max(...inside), 'the furthest true equivalent').toBeLessThan(20); + expect(Math.min(...outside), 'the nearest false positive').toBeGreaterThan(50); + }); +}); diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index 356e8ea25..011fe04e4 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -202,6 +202,57 @@ --st-danger: var(--st-red-text); --st-info: var(--st-blue-text); + /* ── Semantic: status SURFACES ───────────────────────────────────────── + The four above are text and trazo. Nothing here was a surface, so every + component that needed a status band mixed its own: `#5c1a1a`/`#7a5b00` in + the footing mat, `rgba(255,102,0,.13)` in the toolbar's warn banner, + `rgba(221,170,0,.16)` in the outcome badge, `rgba(255,102,0,.08)` in the + verification advice. Four alphas of two hues, none of which was + `--st-amber` or `--st-red`. + + The pattern is `--st-vermillion-dim`'s, which is the only precedent the + file had: the palette hue at a low alpha, so the panel underneath still + reads through it. One alpha for both, 0.14, because two would be a + distinction nobody can use. + + `FootingMatPhysicalPanel` shows what these are NOT for. A status hue as + the colour of a SENTENCE costs more than it buys — 4.89:1 against 14.43:1 + for the same words on a plain well — so a band is still a surface plus a + rule, and these tokens exist for the case where the fill IS the signal: + a 0.68rem badge with no room for a 3px rule. + ──────────────────────────────────────────────────────────────────── */ + --st-danger-bg: rgba(192, 57, 43, 0.14); /* --st-red at --st-vermillion-dim's alpha */ + --st-warn-bg: rgba(184, 134, 11, 0.14); /* --st-amber at the same */ + + /* ── Semantic: provisional ───────────────────────────────────────────── + A fifth status, and the only one the 3-D viewer owns. + + `three/rebar-scene.ts` paints provisional steel `0xa066d3` and feeds that + number to a material, which cannot read a custom property. Two surfaces + already named the same violet by value — `ProvisionalBanner` and + `RebarStatusPanel` — while `FloorFamilyStateCard` sent the same state to + `--st-warn`. One state, two visual meanings, which is worse than either. + + So this is the state's identity, and `shared-status-tokens.test.ts` holds + it equal to the scene's number by RESOLVED VALUE. The dots and meshes stay + literal on purpose: a `var()` in CSS and an `0x` in a material can drift + apart in silence, a duplicated literal with a test comparing them cannot. + + Two strengths, for the reason stated at the top of this file: `#a066d3` is + 4.30:1 on `--st-surface` and 3.77:1 on `--st-surface-3`. It clears the 3:1 + WCAG 2.1 §1.4.11 asks of a dot or a rule and it does NOT clear 4.5:1 for + small text, so a label takes the `-text` variant. Recommending the flat + value for everything would have shipped a legibility regression under the + banner of consistency. + + `-bg` is 0.16 rather than 0.14: it is what `ProvisionalBanner` and + `OutcomeBadge`'s `.badge-provisional` already ship, and matching them means + this token changes no pixel on the two surfaces that had it right. + ──────────────────────────────────────────────────────────────────── */ + --st-provisional: #a066d3; /* = 0xa066d3. Dots, rules, meshes. */ + --st-provisional-text: #d8b4ff; /* 9.58 on --st-surface. Labels. */ + --st-provisional-bg: rgba(160, 102, 211, 0.16); + --st-focus: #6fb0ea; /* ── Semantic: engineering ───────────────────────────────────────────── From 695265ba02096f57d57a653546510d132e589f02 Mon Sep 17 00:00:00 2001 From: Bauti Date: Fri, 21 Aug 2026 11:10:50 -0300 Subject: [PATCH 07/36] refactor(design): the consumers adopt the status contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutcomeBadge 14 → 8 ProvisionalBanner 4 → 0 DesignToolbar 12 → 10 concrete surface 87 → 75 `SteelStatusBadge` is untouched, and that is a finding rather than an omission: its state signal is a `repeating-linear-gradient` hatch — desaturated on purpose, so stale reads without hue — and its other two tones are a blue and a grey. None of the three is a tinted status surface, and rule 4 never flagged them. ── The discrepancy this contract existed to close ───────────────── `FloorFamilyStateCard` sent `provisional` to `--st-warn` while `ProvisionalBanner`, `RebarStatusPanel`, `OutcomeBadge` and the 3-D scene all painted it `#a066d3`. One state, two visual meanings. It now takes the violet, and takes it in TWO strengths, which is not a stylistic choice: the rule gets `--st-provisional` and the 0.7rem badge gets `--st-provisional-text`, because the plain token measures 4.30 on `--st-surface` and 3.77 on `--st-surface-3` — over the 3:1 §1.4.11 asks of a rule, under the 4.5 a small label needs. The `-text` variant is 9.58 and 8.41 on the same two. ── `--st-accent` was reading results as actions ─────────────────── `.badge-fail` and `DesignToolbar`'s `.c-fail` / `.c-sect` were the brand vermillion — the same fill this application puts on destructive BUTTONS. All three go to `--st-danger`. Said plainly because the commit should not claim more than it did: `.c-fail` and `.c-sect` were ALREADY indistinguishable from each other and still are. This fixes the token, not that. ── One badge that could not take the obvious tokens ─────────────── `.badge-outcome-SECTION_INADEQUATE` was three hues in one chip: an orange fill, an accent label, a warn border. The direct translation — warn surface, danger label — measures **4.09** over `--st-surface-3` and fails AA on the darkest ground the badge can sit on, which is exactly what rule 1 of the contract forbids. So the severity rides the border and the words stay at `--st-text`. That also keeps it distinct from `.badge-warn` (warn on warn) and from `.badge-fail` (danger throughout), which a straight merge would not have. ── An accessibility defect found by migrating, not by looking ───── `.badge-provisional`'s border was `#6b4a8f`: **1.76–2.17** against the band it outlines, well under 3:1. Its fill and label were already the values the token was derived from, so those two are no-ops by design and the border is the only pixel that moves — from nearly invisible to 3.13–3.86. `ProvisionalBanner` is the same story with one addition: the body copy goes from `#e2d3f5` (11.98) to `--st-text` (13.00), so the sentence sits at full contrast and the emphasis carries the state. The shape `FootingMatPhysicalPanel` measured its way into. `.banner-warn` moves orange → amber, authorised. `rgba(255,102,0,.13)` was a fifth amber family living in three files and matching neither `--st-amber` nor `--st-warn` — which is the border this banner already had. Fill and rule now come from one hue. ── The exemption register shrank because a test made it ─────────── Seven `pending` entries left rule 4's list. They did not leave because I remembered: the stale-exemption assertion fails on a reason for a literal that is gone, and it fired the moment the four files were migrated. Three remain, all for commit 3, plus two permanent `contract` entries and three affordances that were never status bands. ── Coverage, measured and then improved ─────────────────────────── `status-token-consumers.spec.ts`, 9 at 1280×720, provisional banner in en/es/pt. The first run passed with four of eight assertions on the "not on this fixture" path, which is a green suite that proves little. So I probed both models rather than guessing: `rc-design-qa-8` yields 8 ok badges and nothing else; `rc-qa-diagnostic` yields **7 warn** and **4 provisional**. The badge tests moved there, and a provisional-badge test was added — which is what puts the border fix above under a browser rather than under a stylesheet reader. Still source-only, and annotated as such in the run: `.badge-fail` (no model produces one), the toolbar banners, `.c-fail`/`.c-sect`, and the floor card's provisional state (every family reports `noElements` on both fixtures). A `continue` in the counts test was replaced, because a loop that skipped both classes passed while measuring nothing. Every `var()` is compared against the token resolved ON the element, not on `:root` — `.workspace` shadows `--st-border`, so a page-level read would be a different measurement — and each replaced literal is asserted absent by value, because a dim warm fill and `--st-warn-bg` are indistinguishable in a screenshot. Gates: unit 374 files / 7001 tests · build tests 14 · production build 17.1 s · typecheck 479 against baseline 479 · css-unused warnings 139, identical · status-token-consumers 9/9 · footing-status-tokens + viewer-panel-tokens + floor-family-states 30/30 · served at 127.0.0.1:4003. --- web/e2e/status-token-consumers.spec.ts | 316 ++++++++++++++++++ .../pro/design/DesignToolbar.svelte | 16 +- .../pro/design/FloorFamilyStateCard.svelte | 17 +- .../components/pro/design/OutcomeBadge.svelte | 35 +- .../pro/design/ProvisionalBanner.svelte | 14 +- .../concrete-design-raw-colours.test.ts | 15 +- .../__tests__/shared-status-tokens.test.ts | 13 +- 7 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 web/e2e/status-token-consumers.spec.ts diff --git a/web/e2e/status-token-consumers.spec.ts b/web/e2e/status-token-consumers.spec.ts new file mode 100644 index 000000000..d63960619 --- /dev/null +++ b/web/e2e/status-token-consumers.spec.ts @@ -0,0 +1,316 @@ +/** + * The consumers of the shared status contract paint what the contract says. + * + * ── Why a browser is required here and not merely nice ───────────── + * + * `shared-status-tokens.test.ts` proves the five tokens exist, that their arithmetic clears AA on + * every ground, and that `--st-provisional` equals what Three.js paints. It reads `tokens.css`. + * What it cannot know is whether the PAGE resolves them: a `var()` inside a component whose + * ancestor shadows the property paints something else entirely, and `.workspace` shadows + * `--st-border` for exactly that reason. So every assertion below compares the colour the + * compositor produced against the token resolved on the same element. + * + * The negatives matter as much. `rgba(255,102,0,.13)` and `--st-warn-bg` are both dim warm + * translucent fills on a dark ground; `#6b4a8f` and `--st-provisional` are both violet borders. + * A screenshot diff would accept either, which is why each is asserted to be ABSENT by value. + * + * ── The one visual change, authorised ────────────────────────────── + * + * `.banner-warn` moves from orange to amber. Its own border was already `--st-warn`, so the fill + * and the rule now come from one hue instead of two. + */ + +import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures'; +import type { Page } from '@playwright/test'; + +test.use({ viewport: { width: 1280, height: 720 } }); + +const resolve = (page: Page, colour: string) => + page.evaluate((c) => { + const el = document.createElement('span'); + el.style.color = c; + document.body.appendChild(el); + const out = getComputedStyle(el).color; + el.remove(); + return out; + }, colour); + +/** A token resolved ON the element that uses it, so a shadowing ancestor is included. */ +const tokenOn = async (target: ReturnType, page: Page, name: string) => + resolve(page, await target.evaluate( + (el, n) => getComputedStyle(el).getPropertyValue(n).trim(), name)); + +/** + * A composited translucent fill, computed in the page. + * + * `getComputedStyle().backgroundColor` returns the DECLARED `rgba(...)`, not what the screen + * shows, so comparing a token's rgba against it is the honest comparison — both sides are the + * declaration. Kept explicit because the instinct is to compare against a flattened colour. + */ +const bgOf = (target: ReturnType) => + target.evaluate((el) => getComputedStyle(el).backgroundColor); + +/** Reach RC Design with a designed model, which is where the badges and banners live. */ +async function design(page: Page, model = 'rc-design-qa-8') { + await loadModel(page, model); + await designAll(page); + await page.getByTestId('pr-stage-design').click(); + await page.getByTestId('pr-cmd-design').click(); +} + +/** + * Open the slabs/walls/foundations disclosure. + * + * `floor-family-state` is ATTACHED before this and hidden, which is a trap: `toBeVisible` failed + * having resolved to a real `
` twenty-three times. The element + * existing says nothing about the disclosure being open. + */ +async function openFloorFamilies(page: Page) { + const disclosure = page.getByTestId('floor-families-disclosure'); + await expect(disclosure).toBeVisible(); + if (await disclosure.getAttribute('open') === null) { + await disclosure.locator('> summary').click(); + } + await expect(page.getByTestId('floor-families')).toBeVisible(); +} + +test.describe('@slow the floor-family card: provisional is violet, not amber', () => { + test.slow(); + + test('the badge takes the -text variant and the rule takes the plain token', + async ({ pro: page }) => { + await design(page); + await openFloorFamilies(page); + const card = page.getByTestId('floor-family-state'); + await expect(card).toBeVisible(); + + const [provisional, provisionalText, warn] = await Promise.all([ + tokenOn(card, page, '--st-provisional'), + tokenOn(card, page, '--st-provisional-text'), + tokenOn(card, page, '--st-warn'), + ]); + // The tokens are distinct on this page, which is the premise of everything below. + expect(provisional).not.toBe(provisionalText); + expect(provisional).not.toBe(warn); + + const state = await card.getAttribute('data-state'); + if (state !== 'provisional') { + /* + * Stated rather than skipped silently. The state is model-dependent, and a conditional + * that returns quietly reads in a report as though it had measured something. + */ + test.info().annotations.push({ + type: 'coverage', + description: `this fixture is in '${state}', not 'provisional' — the violet is ` + + 'asserted at source by shared-status-tokens.test.ts', + }); + // What CAN be checked on any state: the card is not painting provisional's amber. + expect(await card.evaluate((el) => getComputedStyle(el).borderLeftColor)) + .not.toBe(provisional); + return; + } + + expect(await card.evaluate((el) => getComputedStyle(el).borderLeftColor), + 'the rule is the plain violet').toBe(provisional); + expect(await page.getByTestId('floor-state-badge') + .evaluate((el) => getComputedStyle(el).color), + 'the 0.7rem label is the -text variant, which is the whole point of the split') + .toBe(provisionalText); + // And no longer amber, which is the defect this closes. + expect(await card.evaluate((el) => getComputedStyle(el).borderLeftColor)).not.toBe(warn); + }); +}); + +test.describe('@slow the outcome badges', () => { + test.slow(); + + test('a failed badge is danger throughout, and no longer the brand vermillion', + async ({ pro: page }) => { + await design(page, 'rc-qa-diagnostic'); + const badge = page.locator('.badge-fail').first(); + if (!(await badge.count())) { + test.info().annotations.push( + { type: 'coverage', description: 'no failed badge on this fixture' }); + return; + } + const [danger, accent, dangerBg] = await Promise.all([ + tokenOn(badge, page, '--st-danger'), + tokenOn(badge, page, '--st-accent'), + tokenOn(badge, page, '--st-danger-bg'), + ]); + expect(await badge.evaluate((el) => getComputedStyle(el).color)).toBe(danger); + expect(await badge.evaluate((el) => getComputedStyle(el).borderTopColor)).toBe(danger); + expect(await bgOf(badge)).toBe(dangerBg); + // The correction: a result read in the colour of an action. + expect(await badge.evaluate((el) => getComputedStyle(el).color), + 'a status is not the brand accent').not.toBe(accent); + // And not the literal fill it replaced. + expect(await bgOf(badge)).not.toBe(await resolve(page, 'rgba(238, 34, 34, 0.16)')); + }); + + test('a warn badge sits on the amber surface, not on a fifth amber', + async ({ pro: page }) => { + await design(page, 'rc-qa-diagnostic'); + const badges = page.locator('.badge-warn'); + const n = await badges.count(); + expect(n, 'the diagnostic model must produce warn badges').toBeGreaterThan(0); + const badge = badges.first(); + expect(await bgOf(badge)).toBe(await tokenOn(badge, page, '--st-warn-bg')); + expect(await bgOf(badge), 'and not the hand-mixed one') + .not.toBe(await resolve(page, 'rgba(221, 170, 0, 0.16)')); + test.info().annotations.push({ type: 'coverage', description: `${n} warn badges` }); + }); + + test('a provisional badge uses all three provisional tokens, and its border is now visible', + async ({ pro: page }) => { + await design(page, 'rc-qa-diagnostic'); + const badges = page.locator('.badge-provisional'); + const n = await badges.count(); + expect(n, 'the diagnostic model must produce provisional badges').toBeGreaterThan(0); + const badge = badges.first(); + + const [bg, text, border] = await Promise.all([ + tokenOn(badge, page, '--st-provisional-bg'), + tokenOn(badge, page, '--st-provisional-text'), + tokenOn(badge, page, '--st-provisional'), + ]); + expect(await bgOf(badge), 'the fill').toBe(bg); + expect(await badge.evaluate((el) => getComputedStyle(el).color), 'the label').toBe(text); + expect(await badge.evaluate((el) => getComputedStyle(el).borderTopColor), 'the boundary') + .toBe(border); + + /* + * The border is the one real change here, and it is a fix rather than a rename. `#6b4a8f` + * measured 1.76–2.17 against the band it outlines — under the 3:1 WCAG 2.1 §1.4.11 asks of + * a control boundary — and the token is 3.13–3.86. The fill and the label were already the + * values the token was derived from, so those two are no-ops by design. + */ + expect(await badge.evaluate((el) => getComputedStyle(el).borderTopColor), + 'the near-invisible violet is gone').not.toBe(await resolve(page, '#6b4a8f')); + test.info().annotations.push({ type: 'coverage', description: `${n} provisional badges` }); + }); +}); + +test.describe('@slow the toolbar banners and counts', () => { + test.slow(); + + test('the fail and section counts are danger, not the accent', async ({ pro: page }) => { + await design(page, 'rc-qa-diagnostic'); + const seen: string[] = []; + for (const cls of ['.c-fail', '.c-sect']) { + const el = page.locator(cls).first(); + // Annotated rather than skipped. The first version used a bare `continue`, so a run where + // NEITHER count existed passed while measuring nothing at all. + if (!(await el.count())) continue; + seen.push(cls); + const [danger, accent] = await Promise.all([ + tokenOn(el, page, '--st-danger'), tokenOn(el, page, '--st-accent')]); + const colour = await el.evaluate((n) => getComputedStyle(n).color); + expect(colour, `${cls} is danger`).toBe(danger); + expect(colour, `${cls} is not the brand accent`).not.toBe(accent); + } + test.info().annotations.push({ + type: 'coverage', + description: seen.length ? `counts measured: ${seen.join(', ')}` + : 'neither count is on screen — asserted at source only', + }); + }); + + test('a banner, whichever kind appears, paints from a status surface', async ({ pro: page }) => { + await design(page, 'rc-qa-diagnostic'); + const seen: string[] = []; + for (const [cls, token] of [ + ['.banner-block', '--st-danger-bg'], ['.banner-warn', '--st-warn-bg'], + ] as const) { + const el = page.locator(cls).first(); + if (!(await el.count())) continue; + seen.push(cls); + expect(await bgOf(el), `${cls} fill`).toBe(await tokenOn(el, page, token)); + // The two literals that are gone. Both are dim warm fills on a dark ground; a screenshot + // comparison would have accepted either. + for (const gone of ['rgba(238, 34, 34, 0.14)', 'rgba(255, 102, 0, 0.13)']) { + expect(await bgOf(el), `${cls} must not be ${gone}`) + .not.toBe(await resolve(page, gone)); + } + } + test.info().annotations.push({ + type: 'coverage', + description: seen.length ? `banners measured: ${seen.join(', ')}` + : 'no banner on this fixture — fills asserted at source only', + }); + }); +}); + +/** + * The provisional banner, on the model that raises it. + * + * `rc-qa-diagnostic` shows it — `rebar-toggles.spec.ts` depends on the same fact for its + * worst-case rail test. Three languages because the banner is a full-width sentence whose length + * changes per locale, and a band that wraps must not push the panel past 1280. + */ +for (const locale of ['en', 'es', 'pt'] as const) { + test.describe(`@slow the provisional banner in ${locale}`, () => { + test.slow(); + test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } }); + + test('it paints from the three provisional tokens and holds its width', + async ({ pro: page }) => { + await loadModel(page, 'rc-qa-diagnostic'); + await designAll(page); + await page.getByTestId('detailing-disclosure').locator('> summary').click(); + const generate = page.getByTestId('cmd-generate-detailing'); + await expect(generate).toBeEnabled(); + await generate.click(); + await expect + .poll(() => page.evaluate(() => + (window.__stabileo as unknown as { detailingAssemblies(): unknown[] }) + .detailingAssemblies().length), { timeout: 60_000 }) + .toBeGreaterThan(0); + + /* + * Into the workspace. `ProvisionalBanner` renders inside `RebarWorkspace`, not in the + * design panel — generating the detailing is necessary and not sufficient, and the first + * version of this waited a minute for an element that was never going to be mounted. + * Waited on the BUILD COUNTER: the overlay paints before its geometry exists. + */ + const before = await page.evaluate(() => + (window.__stabileo as unknown as { rebarSceneBuilds(): number }).rebarSceneBuilds()); + await openDocumentsStage(page); + await page.getByTestId('doc-3d').click(); + await expect(page.getByTestId('rebar-workspace')).toBeVisible(); + await expect + .poll(() => page.evaluate(() => + (window.__stabileo as unknown as { rebarSceneBuilds(): number }).rebarSceneBuilds()), + { timeout: 120_000 }) + .toBeGreaterThan(before); + + const banner = page.getByTestId('rebar-provisional-banner'); + await expect(banner, 'this model must raise the provisional banner') + .toBeVisible({ timeout: 60_000 }); + + const [bg, border, text, provText] = await Promise.all([ + tokenOn(banner, page, '--st-provisional-bg'), + tokenOn(banner, page, '--st-provisional'), + tokenOn(banner, page, '--st-text'), + tokenOn(banner, page, '--st-provisional-text'), + ]); + expect(await bgOf(banner)).toBe(bg); + expect(await banner.evaluate((el) => getComputedStyle(el).borderBottomColor)).toBe(border); + // The sentence at full contrast, the emphasis carrying the state. + expect(await banner.evaluate((el) => getComputedStyle(el).color)).toBe(text); + const strong = banner.locator('strong').first(); + if (await strong.count()) { + expect(await strong.evaluate((el) => getComputedStyle(el).color)).toBe(provText); + } + // `#e2d3f5` was the body colour. Nearly white, and nearly `--st-text`. + expect(await banner.evaluate((el) => getComputedStyle(el).color)) + .not.toBe(await resolve(page, '#e2d3f5')); + + // A longer sentence must wrap, not widen. + const box = await banner.evaluate( + (el) => ({ scroll: el.scrollWidth, client: el.clientWidth })); + expect(box.scroll, `the banner fits at 1280 in ${locale}`) + .toBeLessThanOrEqual(box.client + 1); + }); + }); +} diff --git a/web/src/components/pro/design/DesignToolbar.svelte b/web/src/components/pro/design/DesignToolbar.svelte index 4d963c0cb..e222b0d56 100644 --- a/web/src/components/pro/design/DesignToolbar.svelte +++ b/web/src/components/pro/design/DesignToolbar.svelte @@ -435,9 +435,13 @@ .counts { display: flex; gap: 9px; flex-wrap: wrap; font-size: 0.72rem; font-family: monospace; } .count { color: var(--st-text-2); } .count-sep { color: var(--st-text-3); } - .c-ok { color: var(--st-ok); } .c-warn { color: var(--st-warn); } .c-fail { color: var(--st-accent); } + /* `.c-fail` and `.c-sect` were both `--st-accent`: the brand vermillion, reading a result + as though it were an action. Both go to `--st-danger`. Worth stating plainly: they were + ALREADY indistinguishable from each other, and still are — this fixes the token, not + that. */ + .c-ok { color: var(--st-ok); } .c-warn { color: var(--st-warn); } .c-fail { color: var(--st-danger); } .c-unavail { color: var(--st-text-2); } .c-stale { color: var(--st-text); } - .c-sect { color: var(--st-accent); } .c-exh { color: var(--st-text); } .c-unsup { color: var(--st-text-2); } + .c-sect { color: var(--st-danger); } .c-exh { color: var(--st-text); } .c-unsup { color: var(--st-text-2); } /* The same violet the 3-D view paints a proposal with. Deliberately still a literal while its neighbours are tokens: `three/rebar-scene.ts` owns this colour as a numeric hex for a Three.js material, and `run-summary-reported.test.ts` asserts that this file agrees with @@ -446,8 +450,12 @@ .banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 5px 9px; border-radius: 4px; font-size: 0.73rem; line-height: 1.45; } - .banner-block { background: rgba(238,34,34,0.14); border: 1px solid var(--st-accent); color: var(--st-text); } - .banner-warn { background: rgba(255,102,0,0.13); border: 1px solid var(--st-warn); color: var(--st-text); } + .banner-block { background: var(--st-danger-bg); border: 1px solid var(--st-danger); color: var(--st-text); } + /* Orange to amber, authorised as a semantic correction: `rgba(255,102,0,.13)` was a fifth + amber family that existed in three files and matched neither `--st-amber` nor + `--st-warn`, which is the very border this banner already used. Now the fill and the + rule come from one hue. */ + .banner-warn { background: var(--st-warn-bg); border: 1px solid var(--st-warn); color: var(--st-text); } .banner-info { background: rgba(127, 212, 204,0.11); border: 1px solid var(--st-hair-strong); color: var(--st-text); } .banner-stale { border: 1px solid var(--st-text-3); color: var(--st-text); background: repeating-linear-gradient(45deg, rgba(138,143,122,0.16) 0 6px, rgba(93,97,84,0.16) 6px 12px); } diff --git a/web/src/components/pro/design/FloorFamilyStateCard.svelte b/web/src/components/pro/design/FloorFamilyStateCard.svelte index 896554363..9925a8375 100644 --- a/web/src/components/pro/design/FloorFamilyStateCard.svelte +++ b/web/src/components/pro/design/FloorFamilyStateCard.svelte @@ -102,7 +102,14 @@ background: var(--st-surface); border-left: 2px solid var(--st-hair-strong); } .fam-state[data-state='error'], .fam-state[data-state='refused'] { border-left-color: var(--st-danger); } - .fam-state[data-state='provisional'] { border-left-color: var(--st-warn); } + /* + Provisional is violet, not amber. + It was `--st-warn` here while `ProvisionalBanner`, `RebarStatusPanel` and the 3-D scene + all painted it `#a066d3` — one state with two visual meanings, which is worse than + either. `--st-provisional` is that same violet, held equal to Three.js by value in + `shared-status-tokens.test.ts`. + */ + .fam-state[data-state='provisional'] { border-left-color: var(--st-provisional); } .fam-state[data-state='designed'] { border-left-color: var(--st-ok); } .fam-state-head { margin: 0 0 6px; display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; } .fam-state-why { font-size: 0.68rem; line-height: 1.45; color: var(--st-text-2); } @@ -113,7 +120,13 @@ background: var(--st-surface-3); color: var(--st-text); } .st-badge[data-state='error'], .st-badge[data-state='refused'] { color: var(--st-danger); } - .st-badge[data-state='provisional'] { color: var(--st-warn); } + /* + The BADGE takes `-text`, the rule above takes the plain token, and that is not a style + preference. `--st-provisional` measures 4.30 on `--st-surface` and 3.77 on + `--st-surface-3`: over the 3:1 WCAG 2.1 §1.4.11 asks of a rule, under the 4.5 a 0.7rem + label needs. `--st-provisional-text` is 9.58 and 8.41 on the same two. + */ + .st-badge[data-state='provisional'] { color: var(--st-provisional-text); } .st-badge[data-state='designed'] { color: var(--st-ok); } .fam-scope { display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; margin: 0; font-size: 0.66rem; } .fam-scope dt { color: var(--st-text-3); font-weight: 600; white-space: nowrap; } diff --git a/web/src/components/pro/design/OutcomeBadge.svelte b/web/src/components/pro/design/OutcomeBadge.svelte index 2adc6289a..267e0e726 100644 --- a/web/src/components/pro/design/OutcomeBadge.svelte +++ b/web/src/components/pro/design/OutcomeBadge.svelte @@ -72,15 +72,24 @@ } .badge-text { font-weight: 500; } .badge-ok { background: rgba(34, 204, 102, 0.16); color: var(--st-ok); border-color: var(--st-ok); } - .badge-warn { background: rgba(221, 170, 0, 0.16); color: var(--st-warn); border-color: var(--st-warn); } - .badge-fail { background: rgba(238, 34, 34, 0.16); color: var(--st-accent); border-color: var(--st-accent); } + .badge-warn { background: var(--st-warn-bg); color: var(--st-warn); border-color: var(--st-warn); } + /* `--st-danger`, not `--st-accent`. The accent is the brand and the fill this application + puts on destructive BUTTONS; a failed verification is a status, and reading it in the + same vermillion made a result look like an action. */ + .badge-fail { background: var(--st-danger-bg); color: var(--st-danger); border-color: var(--st-danger); } /* The same violet the 3-D view paints provisional steel and the detailing panel gives - the state row. One colour, one meaning, on every surface that names it. - Still a literal, unlike its four siblings, because that one meaning has no token: - the authority is `three/rebar-scene.ts`, which feeds a numeric hex to a Three.js - material and cannot read a custom property. Tokenising here alone would split the - definition in two. See the note in the report — `--st-provisional` is owed. */ - .badge-provisional { background: rgba(160, 102, 211, 0.16); color: #d8b4ff; border-color: #6b4a8f; } + the state row. One colour, one meaning, on every surface that names it — and the token + that was owed here now exists, held equal to `three/rebar-scene.ts` by value rather than + by hope. + + The fill and the label are the values this badge already had; they are what the token was + derived from. The BORDER is a real change and a fix: `#6b4a8f` measured 1.76–2.17 against + the band it outlines, well under the 3:1 §1.4.11 asks of a control boundary. The token is + 3.13–3.86. */ + .badge-provisional { + background: var(--st-provisional-bg); color: var(--st-provisional-text); + border-color: var(--st-provisional); + } .badge-unavailable { background: rgba(136, 136, 136, 0.16); color: var(--st-text-2); border-color: var(--st-text-3); } /* Stale = desaturated + hatch, so it is distinguishable without hue. */ .badge-stale { @@ -89,7 +98,15 @@ } .badge-outcome { background: rgba(60, 90, 140, 0.18); color: var(--st-text); border-color: var(--st-info); } .badge-outcome-VERIFIED { background: rgba(34, 204, 102, 0.16); color: var(--st-ok); border-color: var(--st-ok); } - .badge-outcome-SECTION_INADEQUATE { background: rgba(255, 102, 0, 0.16); color: var(--st-accent); border-color: var(--st-warn); } + /* Three hues in one chip became two, and NOT the obvious two. + `--st-danger` as the label on `--st-warn-bg` measures 4.09 over `--st-surface-3` — it + fails AA on the darkest ground this badge can sit on, which is exactly what rule 1 of + `shared-status-tokens.test.ts` forbids. So the severity rides the BORDER and the words + stay at full contrast, which also keeps it distinct from `.badge-warn` above (warn on + warn) and from `.badge-fail` (danger throughout). */ + .badge-outcome-SECTION_INADEQUATE { + background: var(--st-warn-bg); color: var(--st-text); border-color: var(--st-danger); + } .badge-outcome-SEARCH_EXHAUSTED { background: rgba(180, 120, 220, 0.16); color: var(--st-text); border-color: var(--st-text-3); } .badge-flag { background: rgba(70, 80, 100, 0.35); color: var(--st-text); border-color: var(--st-hair-strong); } .badge-flag-edited { color: var(--st-text); border-color: var(--st-info); } diff --git a/web/src/components/pro/design/ProvisionalBanner.svelte b/web/src/components/pro/design/ProvisionalBanner.svelte index f8b338f43..c467a0780 100644 --- a/web/src/components/pro/design/ProvisionalBanner.svelte +++ b/web/src/components/pro/design/ProvisionalBanner.svelte @@ -35,12 +35,16 @@ .provisional-banner { margin: 0; padding: 0.4rem 0.75rem; - /* The same violet the 3-D view paints provisional steel with. One colour, one meaning. */ - background: rgba(160, 102, 211, 0.16); - border-bottom: 1px solid #a066d3; - color: #e2d3f5; + /* The same violet the 3-D view paints provisional steel with. One colour, one meaning — + and now one definition. The three literals here were the values the token was derived + FROM, so adopting it changes no pixel except the body copy, which goes from `#e2d3f5` + (11.98 on this band) to `--st-text` (13.00): the sentence at full contrast, the emphasis + carrying the state. The same shape `FootingMatPhysicalPanel` measured its way into. */ + background: var(--st-provisional-bg); + border-bottom: 1px solid var(--st-provisional); + color: var(--st-text); font-size: 0.76rem; line-height: 1.4; } - .provisional-banner strong { color: #d8b4ff; letter-spacing: 0.02em; } + .provisional-banner strong { color: var(--st-provisional-text); letter-spacing: 0.02em; } diff --git a/web/src/lib/__tests__/concrete-design-raw-colours.test.ts b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts index 81f4e9271..b18775a2c 100644 --- a/web/src/lib/__tests__/concrete-design-raw-colours.test.ts +++ b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts @@ -68,7 +68,6 @@ const CEILING: Record = { 'FloorFamiliesPanel.svelte': 1, 'FootingCadHandoffPanel.svelte': 3, 'FootingMatPanel.svelte': 3, - 'ProvisionalBanner.svelte': 4, // 11 left: three `--text-muted` fallbacks that are LIVE — this panel also mounts in // `DocumentsSection`, outside `.workspace`, where the alias does not exist — and eight values // the 3-D scene owns (six state dots, the conflicted count, the unreinforced rule). @@ -82,16 +81,21 @@ const CEILING: Record = { 'SelectionDetails.svelte': 5, 'VerificationDetail.svelte': 3, // ── shared PRO surface: coordinate before lowering ── - 'DesignToolbar.svelte': 12, - 'OutcomeBadge.svelte': 14, + // 10 left: the diagnostics command's own amber fill and its hover level (an affordance, not + // a status band), two white hovers, a teal info banner and the desaturated hatch. + 'DesignToolbar.svelte': 10, + // 8 left: two greens (no --st-ok-bg exists), a blue, a grey, the SEARCH_EXHAUSTED violet + // that is NOT provisional, a slate flag fill, and the two-tone stale hatch. + 'OutcomeBadge.svelte': 8, // ── 3-D viewer: out of scope for this branch ── 'RebarViewport3D.svelte': 4, 'RebarWorkspace.svelte': 6, }; // 132 at the start of this work. −20 FootingMatPhysicalPanel, −10 RebarStatusPanel, -// −2 RebarScenePanel, −5 ConflictInspector, −4 SelectionDetails, −4 TorsionBanner. -const TOTAL_CEILING = 87; +// −2 RebarScenePanel, −5 ConflictInspector, −4 SelectionDetails, −4 TorsionBanner, +// then the shared contract: −6 OutcomeBadge, −4 ProvisionalBanner, −2 DesignToolbar. +const TOTAL_CEILING = 75; const files = () => readdirSync(DIR).filter((f) => f.endsWith('.svelte')); @@ -134,6 +138,7 @@ describe('the raw-colour debt does not grow', () => { describe('the files already at zero stay there', () => { const AT_ZERO = [ 'DetailingWorkflow.svelte', 'FootingMatPhysicalPanel.svelte', 'TorsionBanner.svelte', + 'ProvisionalBanner.svelte', ]; it('each of them still has none', () => { diff --git a/web/src/lib/__tests__/shared-status-tokens.test.ts b/web/src/lib/__tests__/shared-status-tokens.test.ts index a725fad71..8ff85da87 100644 --- a/web/src/lib/__tests__/shared-status-tokens.test.ts +++ b/web/src/lib/__tests__/shared-status-tokens.test.ts @@ -263,15 +263,12 @@ describe('rule 4 — no component re-mixes a tinted status surface', () => { 'ConflictInspector.svelte|rgba(224,68,74,0.14)': 'contract — the 0.14 fill of `conflicted: 0xe0444a`, which its own border also names.', - // ── pending: has a token, not yet migrated ── - 'OutcomeBadge.svelte|rgba(160,102,211,0.16)': 'pending — provisional badge.', + // ── pending: has a token, not yet migrated (commit 3) ── + // Seven entries left this list when commit 2 migrated `OutcomeBadge`, + // `ProvisionalBanner` and `DesignToolbar`. The stale-exemption assertion below is what + // forced them out: it fails on a reason for a literal that is gone, so the register + // shrinks with the work instead of outliving it. 'OutcomeBadge.svelte|rgba(180,120,220,0.16)': 'pending — the second provisional violet.', - 'OutcomeBadge.svelte|rgba(238,34,34,0.16)': 'pending — fail badge.', - 'OutcomeBadge.svelte|rgba(221,170,0,0.16)': 'pending — warn badge.', - 'OutcomeBadge.svelte|rgba(255,102,0,0.16)': 'pending — SECTION_INADEQUATE badge.', - 'ProvisionalBanner.svelte|rgba(160,102,211,0.16)': 'pending — the canonical provisional use.', - 'DesignToolbar.svelte|rgba(238,34,34,0.14)': 'pending — `.banner-block`.', - 'DesignToolbar.svelte|rgba(255,102,0,0.13)': 'pending — `.banner-warn`, an orange that is not --st-warn.', 'VerificationDetail.svelte|rgba(255,102,0,0.08)': 'pending — the advice band.', 'VerificationDetail.svelte|rgba(180,120,220,0.1)': 'pending — provisional advice.', From 2b06d834c38dcf4c2f72ed5f8aa3347c6bef16b2 Mon Sep 17 00:00:00 2001 From: Bauti Date: Fri, 21 Aug 2026 11:22:05 -0300 Subject: [PATCH 08/36] style(design): the rest of bucket 1, now that the tokens exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FootingCadHandoffPanel 3 → 0 SectionAdviceDialog 2 → 2 FootingMatPanel 3 → 0 VerificationDetail 3 → 1 DesignFamilyPanel 2 → 0 FloorFamiliesPanel 1 → 0 ───────────────────────────────────────────────────────────── 14 → 3 concrete surface 75 → 64 Which is 132 → 64 across the whole effort. ── Two that were only waiting on the contract ───────────────────── `FootingCadHandoffPanel`'s failure list was `#5c1a1a`/`#ffe4e4` — the third and fourth appearance of the pair `FootingMatPhysicalPanel` carried, in the same panel family. It takes `--st-danger-bg` with the rule carrying the severity and the words at `--st-text` (12.82 at worst), which is the form its sibling measured its way into. `VerificationDetail`'s `.advice` was `rgba(255,102,0,.08)` beside a border that was already `--st-warn` — the fourth site of an orange that was never `--st-warn`. Fill and rule now come from one hue. ── A token that three files were writing out by hand ────────────── `rgba(143,163,179, α)` IS `--st-hair` at 0.22 and `--st-hair-strong` at 0.38. `FootingMatPanel` wrote it at 0.25 and 0.3, `FloorFamiliesPanel` at 0.2 — one token approximated three ways, which drifts the day the token changes and nothing reports it. ── A red that only looked like a contract ───────────────────────── `DesignFamilyPanel` used `#e0444a`, which is also `conflicted: 0xe0444a` in the 3-D scene. The value matched and the meaning did not: this is the design RESULTS table and the viewer paints nothing in it. So the token applies here — and reads better, 4.89 at worst against that red's 4.2 — while the identical literal stays frozen in `RebarStatusPanel` and `ConflictInspector`, which the scene really does mirror. The test asserts both halves, because the distinction is only real if the frozen ones stay frozen. ── A near-match refused ─────────────────────────────────────────── `.cert-none` was `rgba(180,120,220,.10)` — a violet, in provisional's hue family, for a state that means "there is no certificate", on a badge whose border and label were already neutral. `--st-provisional-bg` was the near match and the wrong answer: an absence is not a provisional result, and the violet was quietly claiming it was. It went to `--st-surface-3`, which is what its own border and text already said. ── Three stayed, and none of them is forgotten ──────────────────── `SectionAdviceDialog`'s two `rgba(0,0,0,0.6)` are a modal scrim and a drop shadow. `tokens.css` has no scrim token and no shadow token, and three other dialogs write the same value — a shared gap, not this file's to invent. `VerificationDetail`'s `.cert-ok` is `rgba(34,204,102,.10)` and there is no `--st-ok-bg`. The contract shipped two status surfaces deliberately, not four. `OutcomeBadge`'s `.badge-outcome-SEARCH_EXHAUSTED` is the last `pending` entry left in rule 4's register, and it is now marked `open` instead: there is no token for what it means. All three are declared exemptions with their reason, and the assertion that fails on a stale exemption is what keeps that register honest. ── Coverage ─────────────────────────────────────────────────────── `footing-status-tokens.spec.ts` grew to 10 on the chain that was already open — the mat panel's rules against `--st-hair-strong`/`--st-border` with the 0.3 approximation asserted absent, the DESIGNED badge neutral and specifically not reading as either kind of verdict, and the families table's rule. Annotated: one direction card and one DESIGNED badge measured. Source-only and said so in the run: the CAD failure band, which needs an export to run AND fail, and no fixture forces that. Gates: unit 374 files / 7007 tests · build tests 14 · production build 16.6 s · typecheck 479 against baseline 479 · css-unused warnings 139, identical across all three commits · footing-status-tokens 10/10 at 1280×720 in en/es/pt · served at 127.0.0.1:4003. --- .../h1-shared-status-tokens-proposal.md | 26 +++++- web/e2e/footing-status-tokens.spec.ts | 92 +++++++++++++++++++ .../pro/design/DesignFamilyPanel.svelte | 7 +- .../pro/design/FloorFamiliesPanel.svelte | 4 +- .../pro/design/FootingCadHandoffPanel.svelte | 15 ++- .../pro/design/FootingMatPanel.svelte | 10 +- .../pro/design/VerificationDetail.svelte | 10 +- .../concrete-design-raw-colours.test.ts | 20 ++-- .../__tests__/concrete-status-tokens.test.ts | 91 ++++++++++++++++++ .../__tests__/shared-status-tokens.test.ts | 8 +- 10 files changed, 258 insertions(+), 25 deletions(-) diff --git a/docs/handoffs/h1-shared-status-tokens-proposal.md b/docs/handoffs/h1-shared-status-tokens-proposal.md index 0ec292392..d260e831b 100644 --- a/docs/handoffs/h1-shared-status-tokens-proposal.md +++ b/docs/handoffs/h1-shared-status-tokens-proposal.md @@ -10,9 +10,29 @@ M1 no debe editar `tokens.css` ni los consumidores mientras el bloque esté en c | | Commit | Qué | |---|---|---| -| ✅ | **1 — contrato** | los cinco tokens en `tokens.css` + `shared-status-tokens.test.ts` (25 aserciones). **Ningún consumidor tocado.** Esto es lo que M1 tiene que verificar. | -| ⏳ | 2 — consumidores | `FloorFamilyStateCard`, `ProvisionalBanner`, `OutcomeBadge`, `DesignToolbar` | -| ⏳ | 3 — cubeta 1 restante | los 14 literales de hormigón puro | +| ✅ | **1 — contrato** `dfa20d8b` | los cinco tokens en `tokens.css` + `shared-status-tokens.test.ts` (25 aserciones). **Ningún consumidor tocado.** Esto es lo que M1 tiene que verificar. | +| ✅ | **2 — consumidores** `695265ba` | `FloorFamilyStateCard`, `ProvisionalBanner`, `OutcomeBadge`, `DesignToolbar`. `SteelStatusBadge` **sin tocar**: su rayado es un `repeating-linear-gradient` intencional y sus otros dos tonos son azul y gris. | +| ✅ | **3 — cubeta 1 restante** | 14 → 3 literales. Superficie de hormigón **132 → 64**. | + +### Lo que quedó abierto, y por qué no lo inventé + +Tres literales sobreviven en la cubeta 1, los tres por falta de token semántico: + +| Archivo | Literal | Token que faltaría | +|---|---|---| +| `SectionAdviceDialog` ×2 | `rgba(0,0,0,0.6)` | **`--st-scrim`** y un token de sombra. Tres diálogos más escriben el mismo valor (`BatchEditDialog`, `ProLoadsTab`, `ProAutoLoadsDialog`): es un hueco compartido, no de este archivo. | +| `VerificationDetail` | `rgba(34,204,102,0.10)` en `.cert-ok` | **`--st-ok-bg`**. El contrato embarcó dos superficies de estado a propósito, no cuatro. | +| `OutcomeBadge` | `rgba(180,120,220,0.16)` en `.badge-outcome-SEARCH_EXHAUSTED` | ninguno. Es un violeta en la familia de *provisional* para un estado que **no** es provisional, en un badge cuyo borde y etiqueta ya son neutros. `--st-provisional-bg` es el parecido y la respuesta equivocada. | + +Los tres están declarados como exenciones con su motivo en `shared-status-tokens.test.ts`, y una +aserción falla si queda una exención para un literal que ya no existe — así la lista se encoge +con el trabajo en vez de sobrevivirlo. Siete entradas salieron solas cuando el commit 2 migró sus +archivos. + +**Un caso análogo que sí se resolvió sin token nuevo:** `.cert-none` tenía +`rgba(180,120,220,0.10)` —violeta— para "no hay certificado", con borde y texto ya neutros. Fue a +`--st-surface-3`, no a `--st-provisional-bg`: una ausencia no es un resultado provisional, y el +violeta lo estaba insinuando. ### Los valores finales, y los dos deltas contra §2 diff --git a/web/e2e/footing-status-tokens.spec.ts b/web/e2e/footing-status-tokens.spec.ts index dda4b406f..ab68371ff 100644 --- a/web/e2e/footing-status-tokens.spec.ts +++ b/web/e2e/footing-status-tokens.spec.ts @@ -217,3 +217,95 @@ for (const locale of ['en', 'es', 'pt'] as const) { }); }); } + +/** + * The rest of bucket 1, on the same chain that was already open. + * + * `FootingMatPanel` and `FootingCadHandoffPanel` render beside the physical mat, so this reuses + * the setup above rather than paying for it twice. Three languages for the parts whose text + * length changes; one for the colours, which do not. + */ +test.describe('@slow bucket 1 after the contract', () => { + test('the mat panel takes the hair tokens, and its DESIGNED badge stays neutral', + async ({ pro: page }) => { + await openPhysicalMat(page); + const mat = page.getByTestId('footing-mat-design'); + await expect(mat).toBeVisible(); + + expect(await mat.evaluate((el) => getComputedStyle(el).borderTopColor), + 'the sub-panel rule is the strong hairline') + .toBe(await resolvedToken(page, '--st-hair-strong')); + // And not the 0.3 approximation of it that was there. + expect(await mat.evaluate((el) => getComputedStyle(el).borderTopColor)) + .not.toBe(await resolve(page, 'rgba(143, 163, 179, 0.3)')); + + const card = mat.locator('.direction').first(); + if (await card.count()) { + expect(await card.evaluate((el) => getComputedStyle(el).borderTopColor)) + .toBe(await resolvedToken(page, '--st-border')); + } + + const badge = mat.locator('.badge.status-DESIGNED').first(); + if (await badge.count()) { + const [surface3, ok, warn] = await Promise.all([ + resolvedToken(page, '--st-surface-3'), + resolvedToken(page, '--st-ok'), + resolvedToken(page, '--st-warn'), + ]); + expect(await badge.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe(surface3); + // Designed is not verified. The badge must not be reading as either kind of verdict. + for (const [tone, name] of [[ok, 'ok'], [warn, 'warn']] as const) { + expect(await badge.evaluate((el) => getComputedStyle(el).backgroundColor), + `DESIGNED must not look ${name}`).not.toBe(tone); + } + } + test.info().annotations.push({ + type: 'coverage', + description: `direction card: ${await card.count()}, DESIGNED badge: ${await badge.count()}`, + }); + }); + + test('the floor-families table rules are the hairline token', async ({ pro: page }) => { + await openPhysicalMat(page); + // The families panel is the ancestor of the whole foundations flow, so it is already open. + const cell = page.getByTestId('floor-families').locator('table th, table td').first(); + if (!(await cell.count())) { + test.info().annotations.push( + { type: 'coverage', description: 'no table in the families panel on this fixture' }); + return; + } + expect(await cell.evaluate((el) => getComputedStyle(el).borderBottomColor)) + .toBe(await resolvedToken(page, '--st-border')); + expect(await cell.evaluate((el) => getComputedStyle(el).borderBottomColor)) + .not.toBe(await resolve(page, 'rgba(143, 163, 179, 0.2)')); + }); + + test('a CAD failure band, if the export produces one, is the danger surface', + async ({ pro: page }) => { + await openPhysicalMat(page); + const band = page.getByTestId('footing-cad-export-failed'); + if (!(await band.count())) { + /* + * Stated. A failed CAD export needs the export to run AND fail, which no fixture forces, + * so this one is source-only — `concrete-status-tokens.test.ts` asserts the rule set. + */ + test.info().annotations.push({ + type: 'coverage', + description: 'no CAD failure on this fixture — the band is asserted at source only', + }); + return; + } + const li = band.locator('li').first(); + expect(await li.evaluate((el) => getComputedStyle(el).backgroundColor)) + .toBe(await resolvedToken(page, '--st-danger-bg')); + expect(await li.evaluate((el) => getComputedStyle(el).borderLeftColor)) + .toBe(await resolvedToken(page, '--st-danger')); + expect(await band.evaluate((el) => getComputedStyle(el).color)) + .toBe(await resolvedToken(page, '--st-text')); + // The pair that is gone, both of which look close enough to survive a screenshot. + for (const g of ['#5c1a1a', '#ffe4e4']) { + expect(await li.evaluate((el) => getComputedStyle(el).backgroundColor)) + .not.toBe(await resolve(page, g)); + } + }); +}); diff --git a/web/src/components/pro/design/DesignFamilyPanel.svelte b/web/src/components/pro/design/DesignFamilyPanel.svelte index a9491ef85..e3d5ed62f 100644 --- a/web/src/components/pro/design/DesignFamilyPanel.svelte +++ b/web/src/components/pro/design/DesignFamilyPanel.svelte @@ -386,8 +386,11 @@ td { text-align: right; font-variant-numeric: tabular-nums; } td.state { text-align: left; color: var(--st-text-3); } tr.skipped, tr.noElements { opacity: 0.6; } - tr.failed td.state { color: #e0444a; } - .err td { text-align: left; color: #e0444a; font-size: 0.72rem; } + /* `#e0444a` is also `conflicted: 0xe0444a` in the 3-D scene, and that is a coincidence, not + a contract: this is the design results table and the viewer paints nothing in it. So the + token applies — and it reads better, 4.89 at worst against this red's own 4.2. */ + tr.failed td.state { color: var(--st-danger); } + .err td { text-align: left; color: var(--st-danger); font-size: 0.72rem; } .totals { margin: 0.2rem 0 0; font-size: 0.76rem; font-variant-numeric: tabular-nums; } .actions { margin-top: 0.4rem; } .actions .primary { diff --git a/web/src/components/pro/design/FloorFamiliesPanel.svelte b/web/src/components/pro/design/FloorFamiliesPanel.svelte index ea93914f7..e86a11b69 100644 --- a/web/src/components/pro/design/FloorFamiliesPanel.svelte +++ b/web/src/components/pro/design/FloorFamiliesPanel.svelte @@ -490,7 +490,9 @@ .n { font-size: 0.7rem; font-weight: 600; padding: 0.05rem 0.3rem; border-radius: 3px; background: var(--st-hair); } .empty { opacity: 0.75; font-style: italic; } table { border-collapse: collapse; width: 100%; font-size: 0.78rem; } - th, td { text-align: left; padding: 0.2rem 0.4rem; border-bottom: 1px solid rgba(143, 163, 179,0.2); } + th, td { + text-align: left; padding: 0.2rem 0.4rem; border-bottom: 1px solid var(--st-border); + } .num { text-align: right; font-variant-numeric: tabular-nums; } /* Over-utilised is never green. */ .num.over { color: var(--st-text); font-weight: 600; } diff --git a/web/src/components/pro/design/FootingCadHandoffPanel.svelte b/web/src/components/pro/design/FootingCadHandoffPanel.svelte index 6d1766467..90ebb2883 100644 --- a/web/src/components/pro/design/FootingCadHandoffPanel.svelte +++ b/web/src/components/pro/design/FootingCadHandoffPanel.svelte @@ -177,12 +177,21 @@ diff --git a/web/src/components/pro/design/RebarSchematics.svelte b/web/src/components/pro/design/RebarSchematics.svelte index 30565b91b..e591f4d37 100644 --- a/web/src/components/pro/design/RebarSchematics.svelte +++ b/web/src/components/pro/design/RebarSchematics.svelte @@ -127,6 +127,6 @@ .cell { display: flex; flex-direction: column; align-items: center; gap: 2px; } .cap { font-size: 0.64rem; color: var(--st-info); font-weight: 600; } .legend { display: flex; gap: 6px; font-size: 0.62rem; font-family: monospace; color: var(--st-text-2); } - .bad { color: var(--st-accent); font-weight: 700; } + .bad { color: var(--st-danger); font-weight: 700; } .dim { color: var(--st-text-3); } diff --git a/web/src/lib/__tests__/shared-status-tokens.test.ts b/web/src/lib/__tests__/shared-status-tokens.test.ts index 3626950df..d0bea6490 100644 --- a/web/src/lib/__tests__/shared-status-tokens.test.ts +++ b/web/src/lib/__tests__/shared-status-tokens.test.ts @@ -118,6 +118,30 @@ describe('the five tokens exist and resolve to literals', () => { }); }); +/** + * The adopted values, pinned. + * + * The contract was agreed on five specific values. The assertions further down check PROPERTIES + * — derived from the palette, alphas consistent, contrast sufficient — and a property can hold + * for a value nobody agreed to. This is the decision itself, so a change to any of the five is a + * change to this list and therefore a conversation. + */ +describe('the contract holds the five agreed values', () => { + const AGREED: Record = { + '--st-danger-bg': 'rgba(192, 57, 43, 0.14)', + '--st-warn-bg': 'rgba(184, 134, 11, 0.14)', + '--st-provisional': '#a066d3', + '--st-provisional-text': '#d8b4ff', + '--st-provisional-bg': 'rgba(160, 102, 211, 0.16)', + }; + + it('each token is exactly what was decided', () => { + for (const [name, value] of Object.entries(AGREED)) { + expect(resolveToken(name), name).toBe(value); + } + }); +}); + describe('rule 1 — text on a status surface clears 4.5:1 on every ground', () => { for (const { bg, tone } of SURFACES) { for (const ground of GROUNDS) { @@ -349,3 +373,80 @@ describe('rule 4 — no component re-mixes a tinted status surface', () => { expect(Math.min(...outside), 'the nearest false positive').toBeGreaterThan(50); }); }); + +/** + * `--st-accent` means an action or a selection, and nothing else. + * + * ── The defect, measured ─────────────────────────────────────────── + * + * `tokens.css` documents it as "primary action, brand". Eight sites in the concrete design + * surface used it as a STATUS instead, and that was not a naming quibble — it failed WCAG AA at + * every one of them: + * + * as text, on the grounds a panel sits on 3.74 – 4.26 (`--st-danger` is 4.89 – 6.01) + * as an opaque fill with `--st-text` on it 3.69 (the tint form is 12.82 at worst) + * as text on an inverted `--st-text` chip 3.85 (`--st-red` is 6.04) + * + * So this gate is not a style rule with an accessibility footnote. It is the accessibility rule, + * and the semantics happen to agree with it. + * + * Every remaining use is listed with what it means. A new one has to be added here, which forces + * the question "is this an action?" to be answered rather than assumed. + */ +describe('--st-accent stays an action, not a status', () => { + const ALLOWED: Record = { + 'DesignToolbar.svelte|.cmd-cancel { background: var(--st-hair-strong); border-color: var(--st-accent); color: var(--st-text); }': + 'action — cancelling a run is destructive, and the accent is what this application ' + + 'outlines destructive controls with. A border, so the 3:1 bar applies and it clears it.', + 'RebarEditorBeam.svelte|.mini-rm { color: var(--st-text-2); border-color: var(--st-accent); }': + 'action — the remove button. Border again, and the label is --st-text-2.', + "RebarSchematics.svelte|fill={bar.index < 4 ? 'var(--st-accent)' : 'var(--st-warn)'}": + 'neither — it distinguishes corner bars from intermediate ones in a diagram, which is data ' + + 'and not a verdict. Left alone rather than renamed: worth noting, though, that the same ' + + 'circle is stroked --st-danger, so one object carries two red tokens.', + }; + + /** Every line that reaches for the accent, comments stripped. */ + function uses() { + const out: string[] = []; + for (const f of readdirSync(DESIGN).filter((n) => n.endsWith('.svelte'))) { + const src = read(f) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + for (const line of src.split('\n')) { + if (line.includes('var(--st-accent)')) out.push(`${f}|${line.trim()}`); + } + } + return out; + } + + it('every use is an action or a selection, and says so', () => { + const undeclared = uses().filter((u) => !(u in ALLOWED)); + expect(undeclared, 'a use of --st-accent with no stated meaning').toEqual([]); + }); + + it('and the list carries no entry for a line that is gone', () => { + const present = new Set(uses()); + expect(Object.keys(ALLOWED).filter((k) => !present.has(k)), + 'declared uses that no longer exist').toEqual([]); + }); + + it('the numbers behind the rule, so it is not taken on trust', () => { + const accent = resolveToken('--st-accent'); + const text = resolveToken('--st-text'); + // As text on the darkest panel ground: under AA. This is why none of the eight could stay. + expect(contrast(rgb(accent), rgb(resolveToken('--st-surface-3')))).toBeLessThan(4.5); + // As an opaque fill with --st-text on it: under AA as well. + expect(contrast(rgb(text), rgb(accent))).toBeLessThan(4.5); + // And as a BORDER it is fine, which is exactly what the two allowed uses do with it. + for (const g of GROUNDS) { + expect(contrast(rgb(accent), rgb(resolveToken(g))), `accent as a border on ${g}`) + .toBeGreaterThanOrEqual(3); + } + // The replacement clears AA on all four. + for (const g of GROUNDS) { + expect(contrast(rgb(resolveToken('--st-danger')), rgb(resolveToken(g))), `danger on ${g}`) + .toBeGreaterThanOrEqual(4.5); + } + }); +}); From dfc4bd83ac965fb3812cc5bac07a85e38985f2b6 Mon Sep 17 00:00:00 2001 From: Bauti Date: Fri, 21 Aug 2026 22:55:08 -0300 Subject: [PATCH 10/36] =?UTF-8?q?test(design):=20H1-A=20=E2=80=94=20the=20?= =?UTF-8?q?audit=20harness,=20and=20what=20it=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteen runs: four screens × three languages at 1280×720, plus the four at 1024×700. It MEASURES and does not assert — a phase that exists to find things cannot fail on finding them. What it found becomes assertions in H1-B..E, in the files those phases touch. `docs/handoffs/h1a-concrete-flow-audit.md` is the map. ── Four of my own false positives, killed before reporting ──────── The harness first reported 12 overflows, 25 unnamed controls and a contrast of 1.00. All four were mine, and they are the class of mistake that turns an audit into noise: `.sr-only` is clipped BY DEFINITION — `scrollWidth > clientWidth` is what it is for, not what is wrong with it. Twelve findings out of zero. The 25 unnamed controls were every checkbox, radio and range inside a `
-
{t('detailing.review')}
+ +

{t('detailing.review')}

{t('detailing.notLegalSignoff')}

{#if selected.review} @@ -305,7 +308,7 @@ /* One heading level per rank, so the two groups do not compete. */ .documents-stage :global(h3), - .documents-stage :global(h5) { + .documents-stage :global(h4) { margin: 0 0 0.2rem; font-size: 0.75rem; font-weight: 600; diff --git a/web/src/components/pro/design/RebarLayersPanel.svelte b/web/src/components/pro/design/RebarLayersPanel.svelte index 94c85d4e9..9542f586f 100644 --- a/web/src/components/pro/design/RebarLayersPanel.svelte +++ b/web/src/components/pro/design/RebarLayersPanel.svelte @@ -37,7 +37,9 @@
-

{t('detailing.scene.layers')}

+ +

{t('detailing.scene.layers')}

{#each SOLID_KINDS as kind (kind)}