diff --git a/.gitignore b/.gitignore
index 4904049e0..515541e8f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,6 +45,12 @@ web/src/lib/wasm
# Playwright artifacts (traces, videos, HTML report). Baselines under
# web/e2e/__screenshots__/ ARE committed.
+
+# Throwaway diagnostics. Three `__probe*.spec.ts` reached `feat/pro-concrete-h1` through a
+# `git add -A` and would have run in a QA sweep: no assertions, `console.log` output, and one of
+# them with a ten-minute timeout. `__screenshots__` above is the deliberate exception and stays.
+web/e2e/__probe*.spec.ts
+
web/e2e/.artifacts/
web/e2e/.report/
web/test-results/
diff --git a/docs/handoffs/cz-divergence-integration.md b/docs/handoffs/cz-divergence-integration.md
new file mode 100644
index 000000000..9cea0aeec
--- /dev/null
+++ b/docs/handoffs/cz-divergence-integration.md
@@ -0,0 +1,110 @@
+# Divergencia C/Z entre H1 y M1 — problema de INTEGRACIÓN
+
+**Estado: no es un cambio pendiente de H1.** H1 ya aplicó lo que le correspondía en `120f15cc` y
+**no volverá a tocar la convención**. Lo que falta está en la rama de M1 y M2 tiene que verificarlo
+ahí.
+
+Este documento existe para que la divergencia no se descubra en el momento del merge.
+
+---
+
+## 1. El estado de cada rama, hoy
+
+| | `section-shapes.ts` (C) | `cold-formed.ts` (`partsC` / `partsZ`) |
+|---|---|---|
+| **`feat/pro-concrete-h1`** | **cara exterior** ✅ `120f15cc` | *el archivo no existe en este árbol* |
+| **`feat/pro-steel-m1`** | línea media *(sin tocar)* | **línea media** — pendiente |
+
+Las dos ramas calculan **áreas distintas para la misma designación** hasta que integren. En un
+`C 100x50x15x2` son 444 mm² contra 452: `2t²`, un 1,8 % en área y hasta **5,6 % en Iz**.
+
+---
+
+## 2. Por qué H1 hizo sólo la mitad
+
+Es lo que la propia propuesta de M1 pedía:
+
+> *"`section-shapes.ts` no se tocó — contiene también las plantillas de hormigón, así que el cambio
+> no sale de M1 de forma unilateral, y tiene que aplicarse a las dos formas a la vez."*
+
+H1 es dueño de ese archivo y lo aplicó. **La otra mitad no la puede aplicar**: en el árbol de H1
+`lib/profiles/cold-formed.ts` no existe, y tampoco hay ningún Z — las apariciones de `'Z'` en
+`section-drawing.ts` son el comando *closepath* de SVG, que es lo que un grep mío encontró y leyó
+mal la primera vez.
+
+Z entró en la rama de M1 en `01da50cb` (geometría C/Z y gramática de designación) y `8f80481e`
+(`'Z'` en la unión de formas, y el dibujo del zeta). Ninguno está mergeado acá.
+
+---
+
+## 3. Lo que falta, y es de M1
+
+Dos líneas, tal como las escribió M1 en §4 de `m2-lip-convention-proposal.md`†:
+
+```diff
+- { w: t, ht: c, uc: b - t / 2, vc: (h - t) / 2 - c / 2 }, // partsC, labio superior
++ { w: t, ht: c - t, uc: b - t / 2, vc: (h - c - t) / 2 },
+```
+```diff
+- const vLip = (h - t) / 2 - c / 2; // partsZ
++ const vLip = (h - c - t) / 2; // partsZ, y ht: c - t en las dos partes del labio
+```
+
+Y una tercera cosa que **no** es de dibujo y es fácil de olvidar:
+
+**`validateColdFormed` / `lipsCollide` tiene que seguir la cota aflojada.** H1 pasó de
+`c + tf > h/2` a `c > h/2`. Si el validador conserva la vieja, va a **rechazar secciones que el
+cálculo acepta y computa correctamente** — un desacuerdo nuevo, en la dirección opuesta al que se
+está cerrando.
+
+---
+
+## 4. Cómo verificarlo en la rama de M1
+
+La evidencia de H1 está en `h1-cz-convention-evidence.md`. Para el espejo, el criterio de
+aceptación es el mismo y es reproducible:
+
+1. **Integrar el polígono dibujado y comparar contra el cálculo**, para C **y** para Z. H1 lo hace
+ en `cold-formed-lip-convention.test.ts` con Green sobre los vértices de `createCShape`; el
+ mismo método aplica a `createZShape`.
+2. **Comprobar en las dos direcciones**: revertir la convención debe hacer fallar el test por
+ exactamente `2t²` en área. Si no falla, el test no está midiendo el polígono.
+3. **`c <= tf` es un canal sin labio**, no un error. `Math.max(0, c - t)` lo cierra por
+ construcción — el labio útil es ≤ 0 exactamente cuando el dibujo se niega a dibujarlo.
+4. **La cota `c > h/2`**, en `lipsCollide` y en el cálculo, con los mismos tres puntos: 0.049 /
+ 0.050 / 0.0501 sobre una sección de 0.100.
+
+---
+
+## 5. Orden de integración, y el riesgo si se invierte
+
+**El espejo tiene que entrar en la misma integración que `120f15cc`, no después.**
+
+- Si **H1 mergea primero sin el espejo**: `section-shapes.ts` calcula por cara exterior y
+ `cold-formed.ts` por línea media **dentro del mismo árbol**. Dos módulos de secciones que
+ discrepan es peor que la discrepancia actual entre ramas, porque deja de ser evidente.
+- Si **M1 mergea primero sin el espejo**: lo mismo, con los papeles cambiados.
+- Si entran juntos: el árbol queda coherente y los tests de las dos ramas se sostienen.
+
+No hay conflicto de merge que avise: son archivos distintos. **Nada va a fallar en el merge**, y
+esa es exactamente la razón de este documento.
+
+---
+
+## 6. Un efecto que sobrevive a la integración
+
+Independiente de quién mergee primero, y ya anotado en la evidencia de H1:
+
+`snapshot`/`restore` guarda A e I en vez de rederivarlos desde `built.params`. Así que una sección
+`C-custom` **ya guardada** conserva sus números, y una **nueva** obtiene los de cara exterior.
+
+**Un mismo proyecto puede terminar con dos secciones C de la misma designación y distinta área.**
+No es defecto de este cambio —se sigue de que las propiedades se persistan— pero es la clase de
+cosa que aparece como reporte de usuario meses después, y conviene que esté escrita antes.
+
+---
+
+† `docs/handoffs/m2-lip-convention-proposal.md` **no está en este árbol**: vive en
+`feat/pro-steel-m1`, commit `f936f29c`. Se lee con
+`git show f936f29c:docs/handoffs/m2-lip-convention-proposal.md`. Lo cito porque es la fuente de la
+convención, no porque esté acá.
diff --git a/docs/handoffs/h1-cz-convention-evidence.md b/docs/handoffs/h1-cz-convention-evidence.md
new file mode 100644
index 000000000..2aa026bd0
--- /dev/null
+++ b/docs/handoffs/h1-cz-convention-evidence.md
@@ -0,0 +1,159 @@
+# Evidencia para M2 — la convención de cara exterior, aplicada en `120f15cc`
+
+**Rama:** `feat/pro-concrete-h1` · **Commit:** `120f15cc` · **Archivo:** `web/src/lib/data/section-shapes.ts`
+**Test:** `web/src/lib/data/__tests__/cold-formed-lip-convention.test.ts` — 9 aserciones
+
+La convención **no se volvió a cambiar**. Este documento es sólo la evidencia pedida.
+
+---
+
+## 1. A, Iy e Iz contra el polígono
+
+El test integra el contorno que **`createCShape` realmente recorre** —Green sobre sus vértices,
+vía `shape.getPoints(1)`— y lo compara contra lo que devuelve `computeSectionProperties` para los
+mismos parámetros.
+
+| sección | A (mm²) | Iy (mm⁴) | Iz (mm⁴) |
+|---|---:|---:|---:|
+| `C 100x50x15x2.0` | 444.0 | 718 012.0 | 156 865.0 |
+| `C 150x60x20x2.5` | 750.0 | 2 624 843.8 | 378 842.4 |
+| `C 200x75x20x3.0` | 1134.0 | 6 993 042.0 | 834 600.2 |
+| `C 80x40x12x1.5` | 267.0 | 277 071.2 | 60 707.4 |
+
+**Coinciden con el polígono a 1e-12 absoluto en área y 1e-9 relativo en las dos inercias**, en las
+cuatro medidas. Son exactamente los números de la tabla §3.1 de
+`m2-lip-convention-proposal.md`†, reproducidos de forma independiente: yo no copié la tabla, el
+test integra el contorno.
+
+**Tolerancia relativa y no absoluta**, a propósito: las cuatro secciones abarcan de 267 a 1134 mm²
+y de 10⁻⁷ a 10⁻⁵ m⁴, y un solo epsilon no puede ser correcto para las dos puntas.
+
+### Verificado en las dos direcciones
+
+Un test que sólo pasa no prueba que mida algo. Revertí la convención a la línea media y volví a
+correr:
+
+```
+expected 0.000452 to be close to 0.000444 diff 8.0e-6 (C 100x50x15x2.0)
+expected 0.0007625 to be close to 0.00075 diff 1.25e-5 (C 150x60x20x2.5)
+expected 0.001152 to be close to 0.001134 diff 1.80e-5 (C 200x75x20x3.0)
+expected 0.0002715 to be close to 0.000267 diff 4.5e-6 (C 80x40x12x1.5)
+```
+
+Las cuatro diferencias son **exactamente `2t²`** para t = 2, 2.5, 3 y 1.5 mm.
+
+---
+
+## 2. Diferencia antes / después
+
+| sección | A antes | A después | ΔA | `2t²` | ΔIy | ΔIz |
+|---|---:|---:|---:|---:|---:|---:|
+| `C 100x50x15x2.0` | 452.0 | **444.0** | 8.0 | 8.0 | −1.97 % | −4.99 % |
+| `C 150x60x20x2.5` | 762.5 | **750.0** | 12.5 | 12.5 | −1.98 % | −5.01 % |
+| `C 200x75x20x3.0` | 1152.0 | **1134.0** | 18.0 | 18.0 | −2.04 % | −5.58 % |
+| `C 80x40x12x1.5` | 271.5 | **267.0** | 4.5 | 4.5 | −1.85 % | −4.67 % |
+
+**Iz cae bastante más que Iy** —del 4.7 % al 5.6 % contra ~2 %— y vale señalarlo porque la
+propuesta no lo tabulaba: el labio está en la punta del ala, lejos del centroide en z y cerca de
+él en y, así que su brazo pesa mucho más en Iz. Para un perfil conformado flexionado en el eje
+débil el cambio es del orden del 5 %, no del 2 %.
+
+Ninguna de las cuatro cambia de signo ni de orden de magnitud, y todas **bajan**: la convención de
+cara exterior cuenta menos material, nunca más.
+
+---
+
+## 3. `c <= tf` — canal sin labio
+
+El régimen que era peor que un corrimiento de `t/2`. `createCShape` dibuja un canal **sin labio**
+cuando `lip <= tf` (línea 140), y el cálculo sumaba `2·c·tl` igual: la app **calculaba una sección
+con labio y dibujaba una sin labio**.
+
+`Math.max(0, c - tf)` lo cierra **por construcción**, sin guarda nueva: el labio útil es ≤ 0
+exactamente cuando el dibujo se niega a dibujarlo.
+
+Tres aserciones:
+
+1. `c === tf` → A, Iy e Iz coinciden con el contorno dibujado (que es el canal sin labio);
+2. `c < tf` → **computa** en vez de ser rechazado, y coincide con el contorno;
+3. el resultado **iguala a un `U-custom` de la misma chapa** — la forma más fuerte de decir "sin
+ labio, ES el canal".
+
+---
+
+## 4. La cota `c > h/2`
+
+Antes: `c + tf > h/2`. Ahora: `c > h/2`. **Es una relajación**, y es la correcta bajo la
+convención: los labios se tocan cuando sus profundidades por cara exterior suman `h`.
+
+Aserido en tres puntos sobre una sección de h = 100 mm, tf = 2 mm:
+
+| `c` | antes | ahora |
+|---|---|---|
+| 49 mm | **rechazada** (49 + 2 > 50) | aceptada |
+| 50 mm | rechazada | aceptada (exactamente `h/2`) |
+| 50.1 mm | rechazada | rechazada |
+
+**Consecuencia para M2:** `validateColdFormed` / `lipsCollide` tiene que seguir esta cota, o el
+validador va a rechazar secciones que `computeSectionProperties` acepta y calcula bien.
+
+---
+
+## 5. Impacto sobre plantillas y modelos guardados
+
+**Plantillas de hormigón: ninguno.** El cambio está contenido en el `case 'C-custom'`.
+`section-shapes.ts` también tiene `concrete-square`, `concrete-rect`, `U-custom`, etc., y ninguno
+se tocó — verificado por la suite completa: **375 archivos / 7022 tests**, sin un solo fallo, así
+que nada dependía de los valores viejos de C.
+
+**Modelos guardados: no se recalculan.** `snapshot`/`restore` guarda A e I, no los reconstruye
+desde `built.params`. Así que:
+
+- una sección `C-custom` **ya guardada** conserva sus números y ningún resultado de análisis se
+ mueve al abrir un `.ded` viejo;
+- una sección **nueva**, o cualquier flujo que rederive desde `built.params`, obtiene los valores
+ de cara exterior.
+
+Eso implica que **un mismo proyecto puede contener dos secciones C con la misma designación y
+distinta área** si una se creó antes y otra después. No es un defecto de este cambio —es la
+consecuencia de que las propiedades se persistan— pero conviene tenerlo escrito antes de que
+aparezca como sorpresa.
+
+---
+
+## 6. Z: **no está en el árbol de H1**
+
+El brief pedía actualizar C y Z juntas. En `feat/pro-concrete-h1` **no hay Z en absoluto**: ni
+cálculo en `section-shapes.ts` ni caso de dibujo. Las apariciones de `'Z'` en
+`section-drawing.ts` son el comando *closepath* de SVG — lo encontré con un grep y lo leí mal la
+primera vez.
+
+El Z vive en `lib/profiles/cold-formed.ts` de M1 (`partsC` / `partsZ`), agregado en `01da50cb` y
+`8f80481e`, **sin mergear acá**. Su espejo son dos líneas y está en §4 de la propuesta:
+
+```
+vLip = (h - c - t) / 2 y ht: c - t en las dos partes del labio
+```
+
+**Estado actual entre ramas:** la C de H1 sigue la cara exterior; la C **y** la Z de M1 siguen la
+línea media. Son áreas distintas para la misma designación hasta que integren, y el orden importa:
+si se mergea H1 sin el espejo, `cold-formed.ts` y `section-shapes.ts` van a discrepar dentro del
+mismo árbol.
+
+---
+
+## 7. Qué revisar
+
+1. Que los números de §1 coincidan con la tabla de la propuesta. **Coinciden**, y en las dos
+ direcciones.
+2. Que la caída de **~5 % en Iz** (§2) sea la esperada. La propuesta no la tabulaba.
+3. Que la cota aflojada (§4) sea la deseada, y que `lipsCollide` la siga.
+4. **Que el espejo de `cold-formed.ts` entre en la misma integración** (§6). Es lo único que puede
+ dejar el árbol inconsistente.
+
+---
+
+† `docs/handoffs/m2-lip-convention-proposal.md` **no está en este árbol**: vive en
+`feat/pro-steel-m1`, commit `f936f29c`. Se lee con
+`git show f936f29c:docs/handoffs/m2-lip-convention-proposal.md`. Lo cito porque es la fuente de la
+convención, no porque esté acá.
diff --git a/docs/handoffs/h1-export-coverage-and-contract.md b/docs/handoffs/h1-export-coverage-and-contract.md
new file mode 100644
index 000000000..8d2774bce
--- /dev/null
+++ b/docs/handoffs/h1-export-coverage-and-contract.md
@@ -0,0 +1,235 @@
+# Exportaciones — qué está verificado, qué no, y el contrato que falta
+
+**Rama:** `feat/pro-concrete-h1` · **PR:** [#161](https://github.com/lambdaclass/stabileo/pull/161) (draft)
+**Estado: reporte y contrato. Nada implementado por este documento.**
+
+Reemplaza y corrige lo que dije en el cierre de H1-C, donde escribí que *"el contenido de los
+archivos está sin verificar"*. Eso es cierto **del camino de navegador** y falso de los
+renderers, que tienen cobertura unitaria sustancial. La distinción importa y la aclaro abajo.
+
+---
+
+## 1. Las tres rutas
+
+| | qué hace | cómo sale |
+|---|---|---|
+| **XLSX** | `renderSchedule(doc)` → `exportToExcel({ extraSheets })` | descarga, `detailing-rev{n}.xlsx` |
+| **DXF** | `renderDrawings(doc)` → `downloadBlob(..., 'application/dxf')` | descarga, `detailing-rev{n}.dxf` |
+| **Reporte** | `renderReportHtml(doc)` → `window.open` + `print()` | **ventana**, no archivo |
+
+Las tres pasan por `currentDoc()`, así que **consumen la misma instancia del modelo y la misma
+revisión**: un reporte, un juego de planos y una planilla del mismo piso no pueden discrepar sobre
+la revisión, los conflictos o el acero.
+
+---
+
+## 2. XLSX — qué se verificó y qué no
+
+### Verificado en navegador (`h1c-documents-flow.spec.ts`)
+
+- que la descarga **ocurre**;
+- que el nombre es `detailing-rev{n}.xlsx` con la revisión correcta;
+- que el panel pasa de `doc-none` a `doc-readiness` con "Revision 1" — o sea que **la exportación
+ es lo que construye el documento**.
+
+### Verificado en unidad (`document-render.test.ts` y 12 archivos más)
+
+`renderSchedule` está ejercitado por **trece** archivos de test. Ejemplo del tipo de aserción:
+la planilla aplanada contiene `NOT FOR CONSTRUCTION` y `prohibitedOverlap` cuando corresponde, y
+el número de hojas es el esperado.
+
+### **No** verificado
+
+- **Nadie abre el `.xlsx` producido.** `exportToExcel` recibe las filas como `aoa` y la conversión
+ a workbook —la librería, las hojas, los nombres de solapa, el encoding— no se lee de vuelta en
+ ningún test. Se verifica lo que entra, no lo que sale.
+- No hay aserción de que `onlyExtras: true` haga lo que promete: que el archivo contenga **sólo**
+ las hojas del despiece y ninguna del exportador general.
+
+**Qué haría falta:** leer el blob descargado con la misma librería y comprobar nombres de hoja y
+un puñado de celdas. Playwright entrega el `Download`; es un test, no un cambio de producción.
+
+---
+
+## 3. DXF — ruta completa, inspección ausente
+
+### Verificado en unidad
+
+`renderDrawings(doc).dxf` se asserta en `document-render.test.ts`:
+
+ contiene 'SECTION' · 'ENTITIES' · 'EOF' · 'ARC'
+ contiene 'NOT FOR CONSTRUCTION' y 'CONFLICT' cuando corresponde
+ longitud > 1000
+
+Y el generador documenta su formato: **R12 (AC1009)**, polilíneas de barra como
+`POLYLINE`/`VERTEX`/`SEQEND`, secciones de barra como `CIRCLE`, arcos reales como `ARC`
+(`drawings.ts:497-513`).
+
+### **No** verificado
+
+- **La descarga nunca se ejercitó en navegador.** `h1c-documents-flow` descarga el XLSX; el DXF no
+ tiene ni siquiera la aserción de nombre de archivo.
+- **Nadie parsea el DXF producido.** Hay un parser en el árbol —`parseCadDxf`, usado por
+ `cad-classify.test.ts` para la IMPORTACIÓN— y no se lo usa nunca sobre la salida. Un test que
+ exporte y vuelva a parsear cerraría el ciclo con código que ya existe.
+- Que el archivo sea **R12 válido** está afirmado por el generador y no comprobado: `AC1009`
+ aparece en el fuente, no en una aserción.
+
+---
+
+## 4. Reporte — popup, y ningún PDF que inspeccionar
+
+`exportReport` no descarga nada:
+
+```ts
+const w = window.open('', '_blank');
+if (w) { w.document.write(html); w.document.close(); w.focus(); w.print(); }
+else downloadBlob(`detailing-rev${n}.html`, 'text/html', html);
+```
+
+Impreso por el navegador y no por un escritor de PDF empaquetado — mejor tipografía, sin
+dependencia, y el usuario elige el papel. La consecuencia para las pruebas es directa:
+
+- **verificado**: la ventana se abre (`popups: 1`, medido);
+- **no verificado**: el contenido de esa ventana, y **no hay PDF alguno que inspeccionar** —
+ `print()` entrega al diálogo del sistema operativo.
+- El **fallback** a `.html` cuando el popup se bloquea **nunca se ejercitó**. Es la única rama que
+ produce un archivo, y es la que ningún test toca.
+
+**Qué haría falta:** capturar el `popup` en Playwright y asertar sobre su DOM — el HTML es del
+mismo `renderReportHtml` que ya tiene cobertura unitaria, así que lo que faltaría probar es el
+transporte, no el contenido. Y forzar el bloqueo de popups para el fallback.
+
+**Corrección a un reporte mío anterior:** dije que `doc-report` era *"un no-op silencioso"*. No lo
+es — mi sonda esperaba una descarga de una acción que abre una ventana.
+
+---
+
+## 5. `ExportRecord` — el contrato que falta
+
+**El store no registra nada.** No hay `lastExport`, `exports` ni equivalente: las tres funciones
+llaman a `currentDoc()`, escriben un blob y no informan a nadie.
+
+**Lo que eso cuesta:** quien exportó el DXF, editó una zapata y volvió a Documentos no tiene forma
+de saber que el archivo en su carpeta ya no corresponde. El modelo **sí** sabe que hubo
+supersesión —`supersededBy`, `supersededDocuments`— y nada conecta eso con los archivos que
+salieron.
+
+```ts
+export interface ExportRecord {
+ kind: 'report' | 'dxf' | 'xlsx'; // cerrado: un cuarto es una decisión
+ revision: number; // de qué revisión salió — la clave de todo esto
+ seriesId: string; // para que un proyecto con varias series no las mezcle
+ at: string; // ISO-8601, provisto por el LLAMADOR
+ filename: string; // el nombre ofrecido al navegador
+ error: string | null; // null si salió bien; el mensaje ya traducido si no
+}
+```
+
+```ts
+recordExport(r: Omit): ExportRecord | null;
+get exports(): readonly ExportRecord[];
+get staleExports(): readonly ExportRecord[]; // revision !== document.revision.number
+```
+
+`at` lo provee el llamador, **nunca el reloj del store** — la regla que `detailing.svelte.ts` ya
+enuncia sobre sí mismo: *"The store never reads the clock itself; the timestamp comes from the
+action."*
+
+`recordExport` devuelve `null` si no hay documento, mismo patrón que `buildDocument`, para que no
+exista un registro sin serie a la que pertenecer.
+
+**Registrar también los fallos.** Un export que falló es exactamente lo que el usuario no
+recuerda.
+
+---
+
+## 6. Compatibilidad con documentos antiguos
+
+Ésta es la decisión de la que depende que el contrato sea barato o caro.
+
+**`ExportRecord` es estado SEPARADO, no un campo de `DocumentModel`.** Tres razones:
+
+1. `DocumentModel` se serializa dentro del modelo y lo leen tres renderers. Agregarle un campo
+ obliga a versionar el modelo y a decidir qué hace un `.ded` viejo al abrirse.
+2. Un registro de exportaciones **no pertenece al documento**: pertenece al proyecto. El mismo
+ documento puede emitirse tres veces y seguir siendo el mismo documento.
+3. `supersede()` mueve documentos a `supersededDocs` sin tocar los registros, así que un registro
+ puede **sobrevivir** a su documento — que es precisamente lo que hace útil a `staleExports`.
+
+**Migración: ninguna.** Un proyecto guardado sin `exports` se lee con la lista vacía, y una lista
+vacía significa *"no sabemos qué se exportó"*, que es la verdad para todo proyecto anterior.
+
+**Explícitamente prohibido: inventar un registro retroactivo.** Que exista un documento **no
+prueba** que se haya exportado. Derivar registros de la existencia de un `DocumentModel` produciría
+una lista de emisiones que nunca ocurrieron, en la única superficie del producto cuyo propósito es
+decir qué salió realmente.
+
+**Persistencia: decisión abierta.** Si va al `.ded` hay que versionar; si vive sólo en memoria se
+pierde al recargar, justo cuando el aviso de obsolescencia más sirve. Recomiendo persistir con el
+campo **opcional** y ausencia = lista vacía, lo que evita el bump de versión.
+
+---
+
+## 7. Qué debería mostrar la UI
+
+- **qué se emitió y de qué revisión** — una línea por registro, con el nombre del archivo;
+- **cuáles quedaron viejos** — `staleExports`, con la revisión que tienen contra la vigente;
+- **los fallos**, que hoy desaparecen apenas se cierra el diálogo.
+
+Y va donde ya está el resto del contenido: la etapa de Documentos hoy muestra readiness, revisión,
+madurez, conjuntos, certificados, cláusulas y reglamentos (`doc-contents`). Un bloque de emisiones
+es la pieza que falta al lado de ésos.
+
+---
+
+## 8. Qué NO puede afirmar el navegador
+
+Conviene dejarlo escrito antes de que alguien lo pida:
+
+- **que el archivo siga existiendo en el disco del usuario.** El navegador entrega el blob y
+ pierde de vista el archivo. "Exportado" significa "se ofreció la descarga", no "está ahí".
+- **que el usuario lo haya guardado.** Puede haber cancelado el diálogo. Un `Download` de
+ Playwright tampoco prueba lo contrario.
+- **que el PDF se haya impreso.** `print()` entrega al sistema operativo y no devuelve nada.
+- **que el archivo no haya sido modificado.** No hay hash de lo que salió, y agregarlo no ayudaría:
+ el hash sería del blob generado, no del archivo en el disco.
+
+De ahí que el campo se llame *export* y no *delivery*, y de ahí que **una exportación vieja no sea
+un error**: exportar y después seguir editando es un flujo de trabajo normal. `staleExports` es
+información, no un defecto.
+
+Y una que es de producto, no técnica: **"exportado" no es "emitido para construcción"**.
+`issue-submit` y su cadena de bloqueos existen para lo segundo y deben seguir siendo lo único que
+lo afirme.
+
+---
+
+## 9. Alcance y dueño
+
+`lib/store/detailing.svelte.ts` lo leen **14 componentes**. Agregar tres miembros de sólo lectura
+más un método no rompe a ninguno —nadie los consume todavía— pero es superficie de store, y H1 no
+la toca por su cuenta más allá de la corrección de `retireDocument()` que estaba autorizada.
+
+`DocumentsSection.svelte` **no** es compartido: lo montan `ProRcWorkflowTab` y `DetailingWorkflow`,
+los dos de hormigón. La parte de UI es de H1 en cuanto el contrato exista.
+
+---
+
+## 10. Orden sugerido
+
+1. El tipo y los tres miembros del store, **sin consumidor**.
+2. Las tres llamadas a `recordExport` en `DocumentsSection`, incluida la rama de error.
+3. La lista y el aviso de obsolescencia en la etapa.
+4. Los tests: necesitan un modelo que supersede un documento **después** de exportar, y
+ `rc-design-qa-8` más una edición de geometría ya lo produce, según
+ `footing-document-slice.test.ts`.
+
+Y en paralelo, independientes del contrato y baratos:
+
+- leer el `.xlsx` descargado y comprobar hojas y celdas;
+- **exportar el DXF y volver a parsearlo con `parseCadDxf`**, que ya está en el árbol;
+- capturar el popup del reporte y asertar sobre su DOM;
+- forzar el bloqueo de popups y ejercitar el fallback a `.html`.
+
+Los cuatro son tests, no cambios de producción.
diff --git a/docs/handoffs/h1-manual-qa.md b/docs/handoffs/h1-manual-qa.md
new file mode 100644
index 000000000..bfdfaa7b0
--- /dev/null
+++ b/docs/handoffs/h1-manual-qa.md
@@ -0,0 +1,180 @@
+# H1 — QA manual en `http://127.0.0.1:4003`
+
+**Rama:** `feat/pro-concrete-h1` · **PR:** [#161](https://github.com/lambdaclass/stabileo/pull/161) (draft)
+**Estado: detenida y lista para QA.** Sin trabajo de producto en curso.
+
+Automatizado ya: **683 tests E2E en 61 archivos** y **7029 unitarios**. Lo que sigue es lo que un
+navegador automatizado **no** puede juzgar — que la pantalla se lea bien, que el orden tenga
+sentido, y que un ingeniero entienda qué le están diciendo.
+
+---
+
+## 0. Antes de empezar
+
+```
+http://127.0.0.1:4003
+```
+
+Probá en **1280×720** y en **1024×700**, y en **es / en / pt**. Son los tres idiomas ofrecidos; los
+otros once diccionarios están incompletos a propósito y **no** son parte de este QA.
+
+Modelos que uso abajo, por lo que producen:
+
+| modelo | qué tiene |
+|---|---|
+| `rc-design-qa-8` | 8 miembros, todo verifica. El caso limpio. |
+| `rc-qa-diagnostic` | **68 conflictos**, 5 provisorios, y levanta los banners de provisional y torsión. |
+| `pro-edificio-7p` | 7 pisos, **1310 marcadores** de conflicto, 6 fallados. Tarda ~20 s en detallar. |
+
+---
+
+## 1. Diseño de armaduras — el panel derecho
+
+**Recorrido:** cargar `rc-design-qa-8` → resolver → *Diseño* → *Diseñar todo*.
+
+Mirá:
+
+- **La franja de etapas.** Envuelve en dos filas y la última etapa queda sola abajo. **Es un
+ defecto conocido y no es de H1** — `WorkflowStages` es cromática compartida con la rama
+ metálica, y el arreglo está propuesto en `h1-shared-chrome-proposal.md`. **No lo reportes de
+ nuevo.**
+- **Las familias de pisos.** Antes de correr la pasada de pisos, cada pestaña debe mostrar un
+ **guion**, no un cero. Un cero ahí diría "tu edificio no tiene losas", que es una afirmación
+ sobre el edificio y era una afirmación sobre el botón.
+- **El bloque de estado** debajo: tiene que decir **por qué** no hay dato y **qué hacer**. Si
+ alguna de las dos frases falta o suena a relleno, reportalo.
+- **Contraste.** Toda la copia secundaria debería leerse sin esfuerzo. Si algo se te pierde,
+ anotá el texto exacto: puede ser uno de los 462 sitios de `--st-text-3` que quedaron fuera del
+ alcance de H1 (`h1-text-3-contrast-proposal.md`).
+
+---
+
+## 2. Detallado
+
+**Recorrido:** abrir el disclosure *Detallado* → *Generar detallado coordinado*.
+
+- **La vista previa del plano** vive acá, no en Documentos. Es un hallazgo abierto: quien está en
+ Documentos decidiendo si exportar **no tiene el plano a la vista**. Está en
+ `h1c-documents-audit.md` §8 y es una decisión de flujo, no un bug.
+- El grupo *Hoja* debería verse como los demás grupos de controles del panel, no como un
+ `` nativo.
+
+---
+
+## 3. Documentos
+
+**Recorrido:** *Documentos*.
+
+- Antes de exportar dice **"aún no hay documento"** y **los tres exports están habilitados**. Eso
+ es deliberado: **la primera exportación es la que construye el documento**. Si te parece
+ confuso, ese juicio es exactamente lo que este QA busca — reportalo como claridad, no como bug.
+- Después de exportar el XLSX: se descarga `detailing-rev1.xlsx`, y el panel debe mostrar
+ revisión, madurez, **conjuntos, certificados, cláusulas** y los **reglamentos con su edición**.
+- **Abrí el XLSX.** Ningún test lo hace: se verifica lo que entra, no lo que sale. Mirá nombres de
+ solapa y un puñado de celdas.
+- **Abrí el DXF en un CAD.** Tampoco lo verifica nadie. Debería ser R12 y las barras polilíneas.
+- **El reporte abre una ventana** y manda a imprimir. No hay PDF que inspeccionar; mirá la ventana.
+- **Registrar revisión** está deshabilitado hasta que pongas tu nombre y aceptes los cálculos
+ provisorios, **y los motivos están escritos al lado**. Si el botón está gris sin explicación,
+ eso sí es un bug.
+- La lista de **superseded** conserva las revisiones retiradas, nombradas. No las borra.
+
+Lo que **no** vas a encontrar y no es un olvido: **qué se exportó y cuándo**. El store no lo
+registra y agregarlo necesita un contrato — `h1-export-coverage-and-contract.md`.
+
+---
+
+## 4. Visor 3-D
+
+**Recorrido:** *Documentos* → *Ver en 3D*. Usá `rc-qa-diagnostic` para tener conflictos.
+
+- **La tipografía.** El visor debe verse en la misma fuente que el resto de la app. Si te parece
+ que "cambia de programa" al abrirlo, reportalo con captura — eso era el defecto y debería estar
+ cerrado.
+- **Las cifras** del rail deberían tener ancho fijo: no tienen que bailar al cambiar un filtro.
+- **Capas y familias.** Apagá barras, hormigón, conflictos. Cada una debe cambiar el dibujo.
+- Las **familias vacías** se nombran en vez de desaparecer.
+- **Clickeá un marcador de conflicto** (una esfera chica dentro de la jaula). Debe abrir el
+ inspector con las dos barras nombradas por separado, la separación medida contra la requerida, y
+ botones de centrar y aislar.
+- **Aislar y limpiar**: el foco no debe saltar al principio del documento. Probalo **con teclado**.
+- **Corte por sección**: elegí un eje, movelo. El deslizador recorre el modelo, no un 0..1.
+- **A 1024 px o menos** aparece el botón ☰: colapsa el rail y lo devuelve. A 1280 **no existe**, y
+ eso es deliberado.
+- **`Escape`** cierra y te devuelve al botón que abriste. **`Escape` no cierra una sección
+ desplegable** del panel — es lo estándar para un `` y no es un bug.
+
+---
+
+## 5. Estados que hay que provocar
+
+Con `rc-qa-diagnostic`:
+
+- **Provisorio** — banner violeta arriba del visor. El violeta es el mismo que la escena pinta;
+ si ves dos violetas distintos para el mismo estado, reportalo.
+- **Conflictos** — 68 marcadores, y el documento cae a *borrador de revisión* diciendo cuántos.
+
+Con `pro-edificio-7p`:
+
+- **Fallado** — 6 miembros en rojo, con la palabra al lado. Paciencia: ~20 s de detallado.
+
+**Rechazado** no lo produce ningún modelo del árbol. Se alcanza sólo desde un test. Si en tu QA
+aparece un miembro *Rechazado*, **es información nueva y vale reportarla**.
+
+---
+
+## 6. Lo que NO hay que reportar
+
+Son decisiones tomadas y documentadas. Reportarlas otra vez cuesta tiempo a todos:
+
+| | por qué |
+|---|---|
+| el chevron colgado de la franja de etapas | archivo compartido con la rama metálica; propuesta escrita |
+| `Escape` no cierra un `` | comportamiento estándar; el overlay cierra porque **es** modal |
+| los exports habilitados sin documento | la primera exportación es la que lo construye |
+| el rail sin botón ☰ a 1280 | el rail no se colapsa en escritorio, a propósito |
+| que Documentos no muestre el plano | está en Detallado; mover una vista previa es cambio de flujo |
+| que no diga qué se exportó | necesita un contrato de store, no está inventado |
+| textos en inglés en idiomas **no** ofrecidos | los otros once diccionarios están incompletos a propósito |
+
+---
+
+## 6 bis. Dos cosas que cambiaron al integrar la base (2026-08-26)
+
+`feat/pro-steel-family` avanzó 44 commits mientras H1 estaba cerrada, y el merge trajo dos
+cambios **visibles** que la guía escrita antes no describe. No son defectos: mirálos y confirmá
+que se comportan así.
+
+| qué | antes en H1 | ahora | por qué |
+|---|---|---|---|
+| la barra de progreso de una corrida | invisible — `background: none` | se llena con el color de acción de la app | la base restauró un relleno que la base común había dejado vacío. Al lado sigue el contador en texto: el porcentaje **no** se lee del color |
+| el chip de propuestas en el resumen de diseño | tono ámbar, igual que una advertencia | tono violeta propio | una propuesta no es algo que salió mal. Es el mismo violeta que el visor 3-D le pone al acero provisional y que el badge de `OutcomeBadge` ya usaba |
+
+Lo que **sí** hay que reportar de estos dos: que el violeta del chip y el del visor 3-D se vean
+distintos entre sí. Están atados por un test que compara el color resuelto, así que si a ojo no
+coinciden, hay algo real que mirar.
+
+---
+
+## 7. Lo que ningún test cubre — mirá acá primero
+
+Por orden de probabilidad de encontrar algo:
+
+1. **El contenido del XLSX y del DXF.** Verificados por nombre de archivo, nunca abiertos.
+2. **El HTML del reporte.** Se verifica que la ventana abre, no lo que dice.
+3. **El fallback a `.html`** cuando el navegador bloquea el popup. Nunca corrió.
+4. **`pt` en superficies fuera de hormigón** — 1172 claves faltantes, sobre todo `landing.` y
+ `cad.` (`i18n-coverage-gap.md`).
+5. **Contenido largo real**: nombres de miembro de 60+ caracteres, muchos pisos, muchas familias.
+6. **El visor con el edificio de 7 pisos** durante un rato: órbita, zoom, filtros encadenados.
+
+---
+
+## 8. Cómo reportar
+
+Para que sirva, cada reporte necesita: **modelo**, **ancho**, **idioma**, **la ruta de clicks**, y
+**el texto exacto** de lo que se lee mal. Una captura sin el ancho no se puede reproducir.
+
+Y una distinción que este QA sí puede hacer y los tests no: **"entra en pantalla" no es "se
+entiende"**. La etapa de Documentos encaja perfecto en los seis casos medidos y sigue sin decirte
+qué exportaste. Ese tipo de hallazgo es el más valioso acá.
diff --git a/docs/handoffs/h1-shared-chrome-proposal.md b/docs/handoffs/h1-shared-chrome-proposal.md
new file mode 100644
index 000000000..68b1d9cb6
--- /dev/null
+++ b/docs/handoffs/h1-shared-chrome-proposal.md
@@ -0,0 +1,201 @@
+# Propuesta única para M1 — tres defectos en la cromática compartida de PRO
+
+**Origen:** H1-A y H1-B (`feat/pro-concrete-h1`, [PR #161](https://github.com/lambdaclass/stabileo/pull/161)).
+**Estado: propuesta. Los tres archivos están sin tocar.**
+`WorkflowStages.svelte` y `DesignOverview.svelte` no fueron editados por H1 en ningún commit.
+**Decisión pendiente:** de Bauti y Diego.
+
+Los tres son de una línea. Los tres los ve M1, porque el flujo metálico se renderiza dentro de la
+misma franja de etapas y del mismo censo.
+
+---
+
+## 1. `WorkflowStages` — el chevron colgado
+
+### Líneas exactas
+
+```
+web/src/components/pro/design/WorkflowStages.svelte:131
+web/src/components/pro/design/WorkflowStages.svelte:132
+web/src/components/pro/design/WorkflowStages.svelte:174 display: flex;
+web/src/components/pro/design/WorkflowStages.svelte:175 flex-wrap: wrap; ← envuelve
+web/src/components/pro/design/WorkflowStages.svelte:184 .stage:not(:last-child)::after {
+web/src/components/pro/design/WorkflowStages.svelte:185 content: '›'; ← el chevron
+```
+
+### Medición DOM
+
+`e2e/h1b-panel-navigation.spec.ts`, agrupando los `` por su `top`:
+
+| ancho | fila 1 | fila 2 | `scrollWidth − clientWidth` | alto |
+|---:|---|---|---:|---:|
+| 1280 | `model` `demands` `check` `design` `detailing` | `documents` | 0 | 50 px |
+| 1024 | idem | `documents` | 0 | 50 px |
+| 900 | idem | `documents` | 0 | 50 px |
+| 820 | idem | `documents` | 0 | 50 px |
+
+**Dos hechos que corrigen cómo se venía describiendo el defecto:**
+
+1. **No hay desborde.** `scrollWidth === clientWidth` a los cuatro anchos. Envolver no es
+ desbordar, y buscarlo como desborde es por lo que H1-A lo reportó primero como "no
+ reproduce".
+2. **Es independiente del ancho.** Las mismas dos filas de 1280 a 820. La franja vive en el
+ sidebar PRO de ancho fijo (539 px de `clientWidth`), así que **nunca fue un problema de
+ viewport angosto** — mirar sólo 1280 lo hacía parecer uno.
+
+El chevron sale de `.stage:not(:last-child)::after`. `detailing` **no** es el último hijo, así que
+dibuja un `›` — y es lo último de la fila 1. Un chevron apuntando al final de la línea, y
+`documents` arrancando la fila 2 sin nada que lo preceda.
+
+### Cambio mínimo
+
+```css
+/* Un separador entre pares de la MISMA fila, no después de cada ítem. */
+.stage + .stage::before { content: '›'; color: var(--st-text-2); padding: 0 0.1rem; }
+/* y borrar la regla ::after de la línea 184 */
+```
+
+Un `::before` en el ítem *siguiente* se mueve con él al envolver: si `documents` baja a la fila 2,
+su chevron baja con él y la fila 1 termina en un ítem. Es el mismo glifo y la misma lectura de
+secuencia, en el otro lado de la junta.
+
+Alternativa si se prefiere no envolver: `flex-wrap: nowrap` + `overflow-x: auto` en el `ol`. La
+descarto: seis etapas en 539 px obligan a scroll horizontal en un elemento de navegación, que es
+peor que dos filas.
+
+### Impacto sobre M1
+
+La franja es la misma para los dos flujos. El cambio es puramente de presentación —ni el markup,
+ni los `data-testid`, ni el orden de las etapas se mueven— así que M1 no necesita adaptar nada,
+pero **lo ve**.
+
+### Tests afectados
+
+- `e2e/h1b-panel-navigation.spec.ts` → `no wrapped row ends in a chevron pointing at nothing`
+ está marcado **`test.fail()`**. Al aplicar el arreglo, Playwright lo reporta como **pass
+ inesperado** y hay que sacar la marca. Está puesto así a propósito: un test que *asertara* el
+ defecto habría que invertirlo.
+- `the strip itself never scrolls sideways` (mismo archivo) tiene que seguir pasando.
+
+---
+
+## 2. `WorkflowStages` — contraste
+
+### Líneas exactas y medición
+
+| línea | regla | par | ratio | umbral | ¿pasa? |
+|---|---|---|---:|---:|---|
+| 186 | `color: var(--st-text-3)` en el `.mark` base | `#64798a` sobre `--st-surface` | **3.74** | 4.5 (texto) | **no** |
+| 227 | `.stage-current .mark { color: var(--st-interactive) }` | `#4a8fd4` sobre `--st-surface-3` | **4.36** | 4.5 (texto) | **no** |
+| 229 | `.stage-blocked button { color: var(--st-text-3) }` | `#64798a` sobre `--st-surface` | 3.74 | **3.0** | **sí** — inactivo |
+
+Medido en navegador por `e2e/h1a-audit.spec.ts` a 9.9 px, en los tres idiomas.
+
+**La 229 no es un defecto.** Una etapa bloqueada es un control inactivo, y §1.4.3 exime su texto
+explícitamente. Es exactamente el uso para el que `--st-text-3` quedó reservado.
+
+### Cambio mínimo
+
+```css
+/* 186 */ color: var(--st-text-2); /* 3.74 → 6.49 */
+/* 227 */ .stage-current .mark {
+ border-color: var(--st-interactive); /* el borde SÍ pasa: 4.36 ≥ 3.0 */
+ color: var(--st-text); /* → 15.74 */
+ }
+```
+
+La 227 es el mismo canje que H1 midió tres veces en esta rama: el estado va al **trazo**, las
+palabras a contraste pleno. `--st-interactive` como borde clara el 3:1 de §1.4.11 con 4.36; como
+color de texto no clara el 4.5. No hace falta cambiar el matiz, sólo el rol.
+
+### Impacto sobre M1
+
+La etapa actual y la marca de etapa son de los dos flujos. Cambio visible y de una línea cada uno.
+
+### Tests afectados
+
+`e2e/concrete-copy-contrast.spec.ts` exime hoy `.mark` de forma indirecta (no aparece bajo
+`.pro-panel` con esa clase). Al aplicarse, nada se rompe; conviene **agregar** `.mark` a las
+aserciones nombradas de ese archivo para que quede fijado.
+
+---
+
+## 3. `DesignOverview` — el glifo del censo
+
+### Línea exacta y medición
+
+```
+web/src/components/pro/design/DesignOverview.svelte:278 .glyph { text-align: center; }
+web/src/components/pro/design/DesignOverview.svelte:280 .label { overflow: hidden; text-overflow: ellipsis; }
+web/src/components/pro/design/DesignOverview.svelte:286 .tone-muted { color: var(--st-text-3); }
+```
+
+`.glyph` y `.label` no declaran color: **heredan** de `.tone-muted`. Medición DOM, a 11.2 px:
+
+ 3.74 (need 4.5) .glyph 11.2px rgb(100, 121, 138) on rgb(15, 30, 43) — "not verified"
+ 3.74 (need 4.5) .label 11.2px rgb(100, 121, 138) on rgb(15, 30, 43) — "not verified"
+
+Reproducido en `en`, `es` y `pt` a 1280×720 y 1024×700 — el texto es "not verified" / "sin
+verificar" / "não verificado", que es justamente el estado que un lector **no** debería tener que
+esforzarse por leer.
+
+Las otras tres tonalidades del mismo censo **sí** pasan, porque usan las variantes `-text`:
+`.tone-ok` → `--st-ok` (4.88), `.tone-warn` → `--st-warn` (6.60), `.tone-bad` → `--st-danger`
+(4.89). `tone-muted` es la única que tomó un token de texto que no es AA.
+
+### Cambio mínimo
+
+```css
+/* 286 */ .tone-muted { color: var(--st-text-2); } /* 3.74 → 6.49 */
+```
+
+Una línea. El resto del censo ya está en la convención correcta.
+
+### Impacto sobre M1
+
+`DesignOverview` dibuja el censo metálico con el mismo componente, así que la tonalidad "muted" de
+M1 sube de contraste igual. No hay cambio de API ni de markup.
+
+### Tests afectados
+
+`e2e/concrete-copy-contrast.spec.ts` exime hoy `glyph` y `label` **por nombre, con el motivo
+escrito**. Al aplicarse, hay que **quitar esas dos entradas** de `ALLOWED_BELOW_AA`, y el gate
+empieza a cubrirlas. La exención está nombrada precisamente para que sacarla sea el paso final del
+arreglo y no quede olvidada.
+
+---
+
+## 4. Orden de aplicación
+
+Los tres son independientes, pero este orden deja el árbol verde en cada paso y hace visible el
+efecto de cada uno:
+
+1. **`DesignOverview:286`** — una línea, sin nada más que tocar. Después, quitar `'glyph'` y
+ `'label'` de `ALLOWED_BELOW_AA` en `concrete-copy-contrast.spec.ts` y correrlo: el gate pasa a
+ cubrir el censo.
+2. **`WorkflowStages:186` y `:227`** — dos líneas. Dejar la 229 como está y **documentar en el
+ archivo por qué**, o el próximo lector la "arregla" y baja el contraste de una etapa
+ deshabilitada sin necesidad. Agregar `.mark` a las aserciones nombradas.
+3. **El chevron** — el más visible y el único con riesgo de layout, así que último. Al aplicarlo,
+ `no wrapped row ends in a chevron pointing at nothing` pasa a reportar un **pass inesperado**:
+ sacar el `test.fail()` en el mismo commit.
+
+También pendiente y del mismo dueño: `DesignToolbar:341` (`.group-label`, 3.74) y `:437`
+(`.count-sep`). Están exentos por nombre en el mismo gate. Los dejo fuera de esta propuesta
+porque `DesignToolbar` es la fila de comandos y merece su propia revisión —tiene 10 colores
+crudos y dos niveles de hover en el comando de diagnóstico— no un arreglo de contraste suelto.
+
+---
+
+## 5. Lo que H1 ya hizo, para que no se duplique
+
+- Las cinco superficies de estado (`dfa20d8b`) y sus consumidores (`695265ba`).
+- `--st-accent` dejó de hacer de veredicto en ocho sitios de hormigón, los ocho bajo AA
+ (`e67e2dc2`).
+- La tipografía del visor (`92c061ec`), incluido el hallazgo de que **los controles de formulario
+ no heredan la fuente**: 489 botones y 23 inputs en Arial dentro de `.app-container`. Resuelto
+ sólo dentro del overlay; el caso global está en
+ `docs/handoffs/h1-text-3-contrast-proposal.md` §7 y es de la hoja global, no de H1.
+- Los dos saltos de encabezado (`4d6b008f`).
+- La copia de hormigón fuera de `--st-text-3` (`d0ba026f`), con el token documentado como
+ reservado para inactivo.
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..d260e831b
--- /dev/null
+++ b/docs/handoffs/h1-shared-status-tokens-proposal.md
@@ -0,0 +1,282 @@
+# 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: 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** `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
+
+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
+**asserta que los tres tintes están por debajo de 1.5**, para que nadie los "arregle"
+oscureciéndolos.
+
+---
+
+---
+
+## 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/docs/handoffs/h1-text-3-contrast-proposal.md b/docs/handoffs/h1-text-3-contrast-proposal.md
new file mode 100644
index 000000000..5f06d1cc5
--- /dev/null
+++ b/docs/handoffs/h1-text-3-contrast-proposal.md
@@ -0,0 +1,169 @@
+# Propuesta para M1 — `--st-text-3` no pasa AA como texto, en ningún fondo
+
+**Origen:** H1-A (`feat/pro-concrete-h1`, [PR #161](https://github.com/lambdaclass/stabileo/pull/161)).
+**Estado: propuesta. `tokens.css` sin tocar.** Ningún consumidor migrado.
+**Decisión pendiente:** de Bauti y Diego.
+
+---
+
+## 1. La medición
+
+`--st-text-3` es `#64798a`. Contra los cuatro fondos opacos que un panel puede tener:
+
+| | `--st-bg` | `--st-surface` | `--st-surface-2` | `--st-surface-3` |
+|---|---:|---:|---:|---:|
+| `--st-text` `#f4f7fa` | 16.97 | 15.74 | 15.22 | 13.81 |
+| `--st-text-2` `#8fa3b3` | 7.00 | 6.49 | 6.28 | 5.70 |
+| **`--st-text-3` `#64798a`** | **4.03** | **3.74** | **3.62** | **3.28** |
+
+**Ninguno de los cuatro llega a 4.5:1.** El más estricto es `--st-surface-3` —el pozo, el
+fondo más claro de los cuatro— con 3.28.
+
+Confirmado en navegador por `e2e/h1a-audit.spec.ts`: en la etapa de diseño aparecen a 10.6–11.2 px
+con ratio **3.74** medido sobre el fondo real, no calculado.
+
+## 2. El alcance
+
+| Área | Usos |
+|---|---:|
+`components/pro/` (todo) | 152
+`components/` raíz | 80
+`components/edu/` | 33
+`App.svelte` | 9
+`components/landing/` | 0
+| **total** | **489** |
+| de los cuales `color:` | **462** |
+| `border-color` | 11 |
+| otros (rellenos, tramas) | 16 |
+
+En la superficie de hormigón: **40 usos, 35 como `color:`**.
+**M1 lo usa en 3**, los tres como `color:`: `SteelStatusBadge.svelte:59` (`.tone-neutral`),
+`SteelPanel.svelte:187` (`.muted`) y `:214` (`.refs`).
+
+## 3. El token alternativo — y por qué la respuesta obvia no sirve
+
+Para que el ratio llegue a 4.5:1 hace falta una luminancia relativa mínima:
+
+| sobre | L mínima | `--st-text-3` tiene |
+|---|---:|---:|
+| `--st-bg` | 0.2090 | 0.1822 |
+| `--st-surface` | 0.2292 | 0.1822 |
+| `--st-surface-2` | 0.2387 | 0.1822 |
+| `--st-surface-3` | **0.2683** | 0.1822 |
+
+Candidatos preservando el matiz (H 207°, S 16%):
+
+| Lightness HSL | valor | bg | surface | surface-2 | surface-3 | ¿AA en los 4? |
+|---:|---|---:|---:|---:|---:|---|
+| 47 % *(hoy)* | `#64798a` | 4.03 | 3.74 | 3.62 | 3.28 | no |
+| 50 % | `#6b8294` | 4.56 | 4.23 | 4.09 | 3.71 | no |
+| 54 % | `#778c9c` | 5.23 | 4.85 | 4.69 | 4.25 | no |
+| **58 %** | **`#8396a5`** | 5.97 | 5.54 | 5.35 | **4.86** | **sí** |
+| 62 % | `#8fa0ae` | 6.78 | 6.29 | 6.08 | 5.52 | sí |
+
+**Y acá está el problema real: `--st-text-2` está en lightness 63 %.**
+
+Para que `--st-text-3` sea un color de texto legal tiene que aterrizar a **5 puntos de
+lightness** de `--st-text-2` — 5.54 contra 6.49 sobre `--st-surface`. En ese punto el tercer
+nivel deja de ser un nivel. No es que el valor esté mal elegido: **esta paleta no tiene lugar
+para tres niveles de texto sobre fondo oscuro, y el tercero está ocupando un lugar que no
+existe.**
+
+## 4. Tres caminos, y el que recomiendo
+
+### (A) Redefinir `--st-text-3` a `#8396a5` — **no lo recomiendo**
+
+Pasa AA en los cuatro fondos con una línea. Pero:
+
+- cambia la apariencia de **489 sitios de una vez**, en `pro/`, componentes raíz, `edu` y
+ `App.svelte`, incluidas superficies que ni H1 ni M1 revisan;
+- colapsa la jerarquía de tres niveles a dos y medio, así que la distinción que el token existe
+ para expresar se pierde igual;
+- y no arregla nada conceptual: el token seguiría llamándose "tercer nivel de texto" siendo
+ casi el segundo.
+
+### (B) Redefinir el SIGNIFICADO: `--st-text-3` es deshabilitado e inactivo — **la dirección correcta**
+
+WCAG 2.1 §1.4.3 exime explícitamente el texto de componentes **inactivos**. `--st-text-3` a
+`#64798a` es un color de deshabilitado perfectamente defendible; lo que no es defendible es que
+lleve oraciones.
+
+Eso pide documentar el token como tal en `tokens.css` y migrar los **462 usos `color:`** que
+llevan copia real a `--st-text-2`. Es correcto y es demasiado para una rama: toca `pro/`,
+componentes raíz, `edu` y `App.svelte`.
+
+Usos que **ya** son legítimos bajo (B), sólo en la superficie de hormigón:
+
+ DesignTable:77,78 relleno de barra y trama → gráfico, umbral 3:1
+ DesignTable:227 .caret → glifo de affordance
+ OutcomeBadge:93,96,110,113 bordes y etiqueta de badge
+ VerificationDetail:219,222 bordes
+ DesignToolbar:460 .banner-stale, borde
+ WorkflowStages:229 .stage-blocked button → etapa BLOQUEADA, inactiva
+ DesignFamilyPanel:336 .frow[data-state='skipped'] → estado omitido
+
+### (C) Acotado a H1 ahora, (B) como dirección — **lo que recomiendo**
+
+Migrar a `--st-text-2` sólo las oraciones y etiquetas de la superficie de hormigón, dejar
+`tokens.css` intacto, y documentar en el token que no es AA para texto. Son ~24 sitios, y **17 de
+ellos son archivos que H1 posee**:
+
+ ChangedMembersPanel:102,103 .muted · .empty
+ BatchEditDialog:322,341,342
+ DesignFamilyPanel:328,352,373,387 .census · .hint · .note/.cols · td.state
+ DesignTable:233 .empty
+ DesignFilterBar:202,206 .lbl · .refused
+ FloorFamilyStateCard:132,136 .fam-scope dt · .no-n
+ ProjectRegulationsPanel:347,384 .role-purpose ×8 · .note
+ RebarEditorColumn:137 .sub
+ RebarEditorBeam:145,146,154
+ RebarSchematics:131 .dim
+ VerificationDetail:211,244 .muted · .desc
+
+`ProjectRegulationsPanel` **no es compartido**: sólo lo monta `ProRcWorkflowTab`, que es la
+pestaña de hormigón armado. Concentra 9 de los sitios que la auditoría vio en pantalla.
+
+## 5. Impacto en H1 y M1
+
+**H1** — 24 sitios de oración, 17 en archivos propios. Efecto visible: la copia secundaria de los
+paneles de hormigón sube de 3.74 a 6.49 sobre `--st-surface`. Sin cambio de layout: mismo tamaño,
+mismo peso.
+
+**M1** — 3 sitios. `SteelPanel.muted` y `.refs` son copia secundaria y caen bajo (B) o (C) según
+lo que M1 decida; `SteelStatusBadge.tone-neutral` es la etiqueta de un badge **neutral**, que es
+más cerca de "inactivo" que de oración y puede quedarse.
+
+**Si se elige (A)**, el impacto es de las dos ramas más `edu`, componentes raíz y la app entera, y
+debería ir en un commit propio, antes que cualquier migración, con capturas de las superficies que
+ninguna de las dos ramas mira.
+
+## 6. Archivos compartidos — freno acá
+
+| Archivo · líneas | Contrato | Dueño |
+|---|---|---|
+| `tokens.css` `--st-text-3` | Tercer nivel de texto de toda la aplicación. 489 usos. | **Compartido.** Igual que las cinco superficies de estado: propuesta medida primero. |
+| `WorkflowStages.svelte:186,229` | Franja de etapas — cromática común del workflow PRO; el flujo metálico se renderiza dentro. `:229` es una etapa **bloqueada** y probablemente ya es legítima. | **Compartido.** |
+| `DesignOverview.svelte:286` `.tone-muted` | Censo de resultados, usado por el censo metálico. | **Compartido.** |
+| `DesignToolbar.svelte:341,437,460` | Fila de comandos PRO. | **Compartido.** |
+| `OutcomeBadge.svelte:93,96,110,113` | Referenciado por `SteelStatusBadge`. Los cuatro son bordes o etiqueta de badge; probablemente ya legítimos bajo (B). | **Compartido.** |
+
+## 7. Un hallazgo aparte, del mismo bloque y más grande
+
+Al arreglar la tipografía del visor apareció que **los controles de formulario no heredan la
+fuente**: dentro de `.app-container` hay **489 botones y 23 inputs en Arial**, contra 15 y 13 en
+IBM Plex. `.app-container` declara `font-family: var(--st-sans)` con el comentario *"One
+declaration here reaches every descendant that does not override it"* — cierto para la herencia,
+falso para `button`, `input`, `select` y `textarea`, a los que cada navegador les da su propia
+fuente.
+
+El arreglo es **una regla**:
+
+```css
+button, input, select, textarea { font-family: inherit; }
+```
+
+Su lugar natural es `tokens.css` o `App.svelte`, de los que dependen todas las pestañas PRO y la
+superficie metálica. H1 ya lo resolvió **dentro del overlay** con un `:global()` acotado a
+`.workspace` (12 botones y 13 inputs), precisamente para no tomar la decisión global por su
+cuenta. **Contrato:** la app declara su tipografía una vez y espera que alcance todo.
+**Dueño:** quien sea dueño de la hoja global.
diff --git a/docs/handoffs/h1a-concrete-flow-audit.md b/docs/handoffs/h1a-concrete-flow-audit.md
new file mode 100644
index 000000000..5a5b12ff2
--- /dev/null
+++ b/docs/handoffs/h1a-concrete-flow-audit.md
@@ -0,0 +1,206 @@
+# H1-A — auditoría y mapa del flujo PRO de hormigón
+
+**Rama:** `feat/pro-concrete-h1` · **PR:** [#161](https://github.com/lambdaclass/stabileo/pull/161) (draft)
+**Arnés:** `web/e2e/h1a-audit.spec.ts` — 16 corridas: 4 pantallas × 3 idiomas a 1280×720, más las
+4 a 1024×700 en español. **Mide, no assert**: una fase de auditoría no puede fallar por encontrar
+algo. Lo que encuentre se convierte en aserciones en H1-B…E, dentro de los archivos que esas
+fases toquen.
+
+Nada de este documento está implementado.
+
+---
+
+## 0. Tres falsos positivos propios, antes de cualquier hallazgo
+
+El arnés reportó 12 desbordes, 25 controles sin nombre accesible y un contraste de 1.00 antes de
+que los verificara. **Los tres eran errores míos**, y vale dejarlos escritos porque son la clase
+de error que convierte una auditoría en ruido:
+
+| Reportaba | Por qué era falso |
+|---|---|
+| 12 desbordes en `.sr-only` | El texto para lector de pantalla **está clipeado a propósito**. `scrollWidth > clientWidth` es su definición, no su defecto. |
+| 25 controles sin nombre | Todos eran `input` de tipo checkbox / radio / range **dentro de un `` con texto visible**. Los nombra el envoltorio, correctamente. Mi sonda sólo leía `aria-label` / `title` / `textContent` del control. |
+| contraste 1.00 a 0.1px | Elementos `` de SVG. El `scrollWidth` y el `fontSize` de un nodo SVG no significan lo mismo que en HTML. |
+
+El arnés ahora implementa el algoritmo de nombre accesible (aria-labelledby → aria-label →
+`` envolvente → `label[for]` → title → texto propio → placeholder/value) y excluye SVG y
+`.sr-only`. Y un cuarto error, distinto: la pantalla de **documentos** se midió con el selector
+`.documents`, que es **una tarjeta** dentro de la etapa — 8 nodos. El contenedor real es
+`documents-stage`, 24 nodos. Esa pantalla estaba reportada como limpia sin haber sido mirada.
+
+---
+
+## 1. Layout — resultado negativo, y vale registrarlo
+
+**Ningún desborde horizontal**, en las 4 pantallas × 3 idiomas × 2 anchos. Ni el contenedor ni
+ningún hijo que no sea un scroller intencional. Las cajas se mantienen exactas:
+
+ design 539 / 539 detailing 515 / 515
+ documents 515 / 515 workspace según ventana
+
+Esto **contradice parcialmente** un defecto que PR20 dejó reportado —"la franja de workflow
+envuelve a 1280×720 dejando un chevron colgado"—. Envolver no es desbordar: la franja puede
+seguir envolviendo fea y el contenedor caber. Es una afirmación distinta y necesita otra medición
+(altura de la franja y posición del último ítem), que H1-B tiene que hacer antes de darla por
+buena o por falsa.
+
+**No medí contenido sintéticamente largo.** Sólo los largos que produce `rc-design-qa-8` en tres
+idiomas. Un nombre de miembro de 60 caracteres o un mensaje de norma de tres líneas siguen sin
+medirse; H1-B.
+
+---
+
+## 2. Jerarquía — 2 saltos, los dos en los tres idiomas
+
+| Salto | Dónde | Texto |
+|---|---|---|
+| `h3 → h5` | `DocumentsSection` (se ve también dentro del panel de diseño) | "Engineer review" / "Revisión del profesional" / "Revisão do profissional" |
+| `h2 → h4` | `RebarWorkspace` / `RebarLayersPanel` | "Layers" / "Capas" / "Camadas" |
+
+Los dos son defectos de estructura, no de estilo: un lector que navega por encabezados pierde un
+nivel y no sabe si "Revisión del profesional" es hermana o hija de lo anterior.
+
+---
+
+## 3. Accesibilidad — nombres bien, contraste no
+
+**Nombres accesibles: ninguno falta.** Después de corregir la sonda, cero controles sin nombre en
+las 16 corridas. Es un buen resultado y conviene decirlo con la misma claridad que los defectos.
+
+**Contraste: cuatro defectos, uno de ellos sistémico.**
+
+### 3.1 `--st-text-3` no pasa AA como texto, en ningún fondo
+
+`#64798a` sobre los cuatro fondos de panel:
+
+| | `--st-bg` | `--st-surface` | `--st-surface-2` | `--st-surface-3` |
+|---|---:|---:|---:|---:|
+| `--st-text-3` | 4.03 | **3.74** | **3.62** | **3.28** |
+| `--st-text-2` | 7.00 | 6.49 | 6.28 | 5.70 |
+
+Y se usa como **texto corrido a 0.62–0.7 rem** en diez lugares de la superficie de hormigón:
+
+ ProjectRegulationsPanel .role-purpose (×8, una por rol) · .note
+ DesignOverview .glyph · .label (heredan; medidos a 11.2px)
+ DesignFamilyPanel .hint · .note · .cols
+ DesignFilterBar .lbl · .refused
+ FloorFamilyStateCard .no-n
+ RebarEditorColumn .sub RebarEditorBeam .line.empty
+ ChangedMembersPanel .empty
+
+Esto no se arregla archivo por archivo: o `--st-text-3` deja de usarse para oraciones y queda
+para lo deshabilitado/decorativo, o cambia de valor. **`tokens.css` es contrato compartido** →
+§6.
+
+### 3.2 Tres defectos puntuales
+
+| Ratio | Dónde | Qué |
+|---:|---|---|
+| **4.06** | `DetailingWorkflow` `.progress li.done` | `--st-text` sobre `--st-green` (`#1f8a52`). El paso "hecho" de la barra de progreso. |
+| **4.36** | `WorkflowStages:227` `.stage-current .mark` | `--st-interactive` como **color de texto** sobre `--st-surface-3`. Ya medí esto para bordes: 4.36 pasa el 3:1 de §1.4.11 y no el 4.5 de texto. Acá es texto. |
+| **3.74** | `DesignOverview:278,280` `.glyph` / `.label` | Heredan `--st-text-3`. Mismo problema que §3.1. |
+
+---
+
+## 4. Estilo — el hallazgo central
+
+### El visor no usa la tipografía de la aplicación
+
+Medido, no impresión:
+
+| Pantalla | Fuente predominante | `IBM Plex Mono` presente |
+|---|---|---|
+| design | `IBM Plex Sans` | sí |
+| detailing | `IBM Plex Sans` | sí |
+| documents | `IBM Plex Sans` | — |
+| **workspace** | **`-apple-system`** | **no** |
+
+Y confirmado en el navegador, no inferido del CSS:
+
+```
+insideAppContainer: false
+wsParent: DIV.(none)
+wsFont: -apple-system appFont: "IBM Plex Sans"
+wsMono: false
+```
+
+**El mecanismo exacto.** `App.svelte:1196` renderiza ` ` como **hermano** de
+`.app-container`, y `.app-container` es el único elemento que declara
+`font-family: var(--st-sans)` — con un comentario que dice, en sus propias palabras: *"One
+declaration here reaches every descendant that does not override it."* El visor no es
+descendiente. Hereda el stack del sistema de `index.html:74`.
+
+**Y el punto de montaje es correcto.** El comentario de `App.svelte:1190` lo explica: el
+lanzador vive en `aside.pro-sidebar`, que tiene ancho fijo en píxeles, y anidar el visor ahí lo
+dejaba de unos cientos de píxeles de ancho. Escapar del contenedor fue deliberado. **Escapar de
+la tipografía no.** El arreglo no es mover el montaje: es declarar la fuente en `.workspace`,
+igual que `.app-container`.
+
+Esto es la capa que faltaba del arreglo que ya hizo `viewer-design-system.test.ts`. Ese test
+existe porque *"el visor 3D parece una aplicación distinta"* y aliaseó los cuatro custom
+properties de **color** que caían a literales. La tipografía quedó afuera. Y `wsMono: false`
+significa que **ninguna cifra del visor tiene figuras tabulares**, mientras cada columna numérica
+del resto de PRO las tiene — que es la razón por la que `--st-mono` existe.
+
+### Radios: el visor no es el desalineado
+
+| Pantalla | Radios distintos |
+|---|---|
+| design | `3px`, `4px`, `50%`, **`10px`**, **`4px 0 0 4px`**, **`0 4px 4px 0`** |
+| detailing | `3px`, `4px` |
+| documents | `4px` |
+| workspace | `4px`, `50%`, `3px` |
+
+`tokens.css` define `--st-radius: 3px` y `--st-radius-lg: 6px`. El `10px` y los dos radios
+partidos de la etapa de diseño no son ninguno de los dos. El visor, acá, es el más consistente
+de las cuatro — lo cual es exactamente la clase de diferencia que hay que **no** "arreglar".
+
+---
+
+## 5. Lo que esta auditoría NO cubrió
+
+Decirlo importa tanto como los hallazgos, porque una fase que se declara cerrada sin haber mirado
+es peor que una abierta.
+
+- **Navegación y transiciones.** Medí pantallas en reposo. No medí etapa→etapa, ni el foco al
+ abrir y cerrar el visor, ni `Escape`, ni a qué control vuelve el usuario. Primer trabajo de
+ H1-B.
+- **La máquina de estados real.** No leí `rebarWorkspace` ni `design-run` contra la UI. Los siete
+ estados de pisos están verificados; los del visor (aislamiento, filtros, opacidad, conflictos)
+ no.
+- **Contenido largo sintético.** Sólo los largos del fixture.
+- **Comportamientos interactivos del visor** más allá de lo que se dibuja en reposo.
+- **Que tokenizar no cierra nada.** La superficie de hormigón está en 64 crudos desde 132, y este
+ documento encuentra cuatro defectos de contraste y uno de tipografía en archivos **ya
+ tokenizados**. El color correcto no implica el contraste correcto.
+
+---
+
+## 6. Frontera de archivos compartidos — freno acá
+
+Cuatro de los hallazgos viven en archivos que M1 también necesita. Reporto y no edito.
+
+| Archivo · líneas | Contrato | ¿Quién debería ser dueño? |
+|---|---|---|
+| `tokens.css` `--st-text-3` (#64798a) | Token de texto de tercer nivel, usado por PRO entero y por la landing. Subirlo mueve toda la app; dejar de usarlo para oraciones es cambio por consumidor. | **Contrato compartido.** Igual que las cinco superficies de estado: propuesta medida primero, implementación por H1 sólo si M1 acuerda. |
+| `WorkflowStages.svelte:206,220,227` | La franja de etapas es la cromática común del workflow PRO; el flujo metálico de M1 se renderiza dentro. `.mark` es el indicador de etapa actual. | **Compartido.** El arreglo (4.36 → `--st-text` o `--st-text-2` con el borde llevando el estado) es de una línea, pero la ve M1. |
+| `DesignOverview.svelte:278,280,283-285` | El censo de resultados. M1 lo usa para el censo metálico. | **Compartido.** |
+| `App.svelte:1196` + `.app-container:1334` | El punto de montaje del overlay y la única declaración de fuente de la app. | **La declaración de fuente va en `.workspace`** (`RebarWorkspace.svelte`), que es de H1 — no en `App.svelte`. Eso lo hace un cambio **no compartido**, y es la razón para preferirlo sobre mover el montaje. |
+
+`ProjectRegulationsPanel` concentra 9 de los 10 sitios de `--st-text-3`: hay que confirmar si el
+panel de reglamentos lo comparte la superficie metálica antes de tocarlo. No lo verifiqué.
+
+---
+
+## 7. Plan de fases, con lo que ya está anclado
+
+| Fase | Primer trabajo, según lo medido |
+|---|---|
+| **H1-B** panel derecho y navegación | Medir foco y transiciones (lo que §5 dejó abierto). Cerrar el salto `h3 → h5`. Volver a medir la franja de workflow como *envoltura*, no como desborde. Contenido largo sintético. |
+| **H1-C** documentos y planos | La etapa tiene 24 nodos con el fixture: hay que llegar a un estado con planos reales antes de auditarla de verdad. El salto de encabezado vive acá. |
+| **H1-D** visor y rail | La fuente y el mono en `.workspace` — un cambio, no compartido, con el mayor efecto visible del bloque. El salto `h2 → h4`. Los estados interactivos del rail. |
+| **H1-E** integración y gates | Los cuatro contrastes, los tres que dependen de archivos compartidos según §6, y las aserciones que reemplacen a este arnés. |
+
+Los colores de Three.js siguen siendo autoridad del visor: `conflicted 0xe0444a`,
+`unreinforced 0xd4762a`, `selected 0xffd400`, `provisional 0xa066d3`. Ninguna fase los toca, y el
+espejo se sigue verificando en las dos direcciones.
diff --git a/docs/handoffs/h1c-documents-audit.md b/docs/handoffs/h1c-documents-audit.md
new file mode 100644
index 000000000..1c0247055
--- /dev/null
+++ b/docs/handoffs/h1c-documents-audit.md
@@ -0,0 +1,317 @@
+# H1-C — auditoría real del flujo de documentos y planos
+
+**Rama:** `feat/pro-concrete-h1` · **PR:** [#161](https://github.com/lambdaclass/stabileo/pull/161) (draft)
+**Arnés:** `web/e2e/h1c-documents-flow.spec.ts` — 17 corridas, 3 idiomas × 2 anchos.
+
+---
+
+## 0. H1-A había reportado esta etapa como limpia. No la había mirado
+
+H1-A midió `documents` con el selector `.documents`, que es **una tarjeta** dentro de la etapa: 8
+nodos. El contenedor real es `documents-stage`. Y nunca construyó un documento, así que midió el
+estado vacío y lo reportó sin defectos.
+
+Con el contenedor correcto y un documento construido son **18 nodos de texto**, y aparecen cuatro
+hallazgos, uno de ellos con pérdida de datos.
+
+---
+
+## 1. La cadena, medida
+
+**estado → exportación → revisión → aceptación → emisión**, y funciona así:
+
+| Paso | testid | Qué se midió |
+|---|---|---|
+| estado sin documento | `doc-none` | "No document built yet" · los 4 exports **habilitados** |
+| exportación | `doc-xlsx` | descarga real: **`detailing-rev1.xlsx`** |
+| | `doc-report` | abre 1 ventana (`window.open` + `print()`), no descarga |
+| estado con documento | `doc-readiness` `doc-revision` `doc-maturity` | "Revision 1" |
+| aceptación | `ack-*` | 1 cálculo provisorio en este fixture |
+| revisión | `review-submit` | ver §3 |
+| emisión | `issue-submit` + `issue-blockers` | deshabilitado, con el motivo en texto |
+
+**El documento se construye de forma perezosa: lo construye la primera exportación.** Antes de
+eso la etapa dice "aún no hay documento" y ofrece los tres exports habilitados. Es deliberado y
+funciona.
+
+### Una corrección a mi propia primera lectura
+
+Reporté un "no-op silencioso" en `doc-report`. **Estaba equivocado.** Mi sonda esperaba una
+descarga, y `exportReport` usa `window.open` + `print()`. Y leí el estado del panel *antes* de
+exportar, así que vi `doc-none` y concluí que exportar no hacía nada. Medido bien: `doc-xlsx`
+descarga el archivo, el panel pasa a `doc-readiness` y `doc-revision` dice "Revision 1". El
+código además tiene su rama de error (`docError = t('detailing.doc.noCoordinated')`), que yo
+había leído como ausente.
+
+---
+
+## 2. Qué pasa cuando no hay planos
+
+`documents-empty` — "emptyStage" — cuando no hay conjunto seleccionado, con un comentario en el
+fuente que dice lo correcto: *"Not a blank stage: the reason there is nothing to export, and where
+to get one."* Y `doc-none` cuando hay conjunto pero no documento.
+
+Los dos son estados honestos. **No hay cero fabricado en esta etapa.**
+
+---
+
+## 3. El defecto con pérdida de datos — `retireDocument` antes de validar
+
+### El mecanismo
+
+`src/lib/store/detailing.svelte.ts:1379-1396`:
+
+```ts
+review(record) {
+ if (!selected) return false;
+ retireDocument(); // ← 1380: incondicional
+ const r = applyReview(selected, record, provisionalKeys(selected));
+ if (!r.ok || !r.assembly) {
+ lastError = …; // ← el motivo del rechazo
+ return false; // ← y el documento ya fue retirado
+ }
+ …
+}
+```
+
+`retireDocument()` incrementa la revisión, mueve el documento a `supersededDocs` y pone
+`currentDocument = null`. Se ejecuta **antes** de que `applyReview` decida.
+
+### Medido
+
+Con el `Record review` original —sin `disabled` y sin explicación— y un cálculo provisorio sin
+aceptar:
+
+ antes: doc-readiness = 1, doc-revision = "Revision 1"
+ click: review-error = "There are provisional calculations without express acceptance…"
+ después: doc-readiness = 0, doc-maturity ausente, superseded-docs = 1
+
+**Un click que no logró nada supersedió el documento que el usuario acababa de construir.** Tiene
+que volver a exportar para recuperarlo.
+
+Aclaración importante: un `review` **exitoso** también retira el documento, y eso **es correcto** —
+el comentario del store lo dice: *"A review changes the readiness a document may claim, so the
+previous one is no longer current."* El defecto es el camino del rechazo, no el del éxito.
+
+### Lo que H1 hizo, y lo que NO
+
+**Hecho, a nivel componente:** `review-submit` ahora está `disabled` mientras haya un motivo, y
+los motivos se muestran en `review-blockers` — **con las mismas tres frases que el store diría
+después**, desde las mismas claves de i18n:
+
+ detailing.review.notConstructible ← espeja assembly.ts:481
+ detailing.review.engineerRequired
+ detailing.review.provisionalOutstanding
+
+No es un juego de reglas nuevo: son las mismas, dichas antes del click. Que es el principio que la
+nota bajo `issue-submit` ya enunciaba — *"a control that governs a construction issue and explains
+itself with nothing but grey is the one place in this panel where silence is least excusable"*—
+aplicado al control que se había olvidado.
+
+Medido después: `disabled` con dos motivos → nombre → un motivo → aceptaciones → **habilitado**, el
+documento intacto, y la revisión **tiene éxito** con `issue-submit` habilitado.
+
+**Hecho después, autorizado: el orden en el store.** `retireDocument()` ahora corre **después**
+del `if (!r.ok)`. Cambio de una línea movida, con test de regresión E2E que **verifiqué que falla**
+con el orden anterior (`"the document survives — element(s) not found"`) y pasa con el nuevo.
+
+Por qué ningún test unitario lo cubría: `detailingStore.assemblies` lo puebla la pasada de
+detallado **por miembro**, y el fixture de zapatas de `footing-document-slice.test.ts` lo deja
+vacío — ahí un `review()` sale por `if (!selected)` devolviendo `false` **sin `lastError`**, que es
+un rechazo por el motivo equivocado. Ese archivo ahora lo dice en el lugar en vez de pasar por
+encima. La cobertura real está en `e2e/h1c-documents-flow.spec.ts`, vía
+`__stabileoActions.reviewAssembly` — el hook de mutación, no `__stabileo`, que es de sólo lectura
+por diseño.
+
+**Contexto original del reporte:** `detailing.svelte.ts` lo importan **14 componentes**, cuatro
+de ellos congelados (`ProRibbon`, `WorkflowStages`, `DesignOverview`, `StageSection`). Ningún
+archivo de acero lo usa, pero reordenar `review()` cambia el comportamiento para todos los
+consumidores.
+
+**Contrato:** `review()` promete devolver `false` y poblar `lastError` cuando el motor rechaza. Hoy
+también supersede el documento en ese camino. **Cambio mínimo:** mover `retireDocument()` después
+del `if (!r.ok)`. **Dueño:** quien sea dueño del store de detallado. **Tests afectados:** cualquiera
+que dependa de que un `review` rechazado incremente la revisión — hay que buscarlos antes de
+mover la línea.
+
+Con el gating del componente, el camino del rechazo **no es alcanzable desde este panel**. El
+defecto sigue existiendo para cualquier otro llamador.
+
+---
+
+## 4. Un hueco de i18n que mi propio cambio dejó a la vista
+
+Al mostrar los motivos **antes** del click, el tripwire de idioma del spec falló en portugués:
+
+ pt · review-blockers → "The reviewing engineer must be named. There are provisional…"
+
+Las cinco claves `detailing.review.*` existían en `en` y `es`. **En `pt` había una de cinco.** Un
+usuario en portugués era rechazado en inglés.
+
+### Por qué ningún gate lo cazó
+
+Dos causas, las dos mecánicas:
+
+- `locale-parity.test.ts:63` filtra `k.startsWith('design.')`. El namespace `detailing.` no está
+ vigilado.
+- `pro-flow-coverage.test.ts` escanea componentes y `lib/engine/detailing`. Estas claves las emite
+ el **store**, y los stores no están en su lista de escaneo — el propio store lo dice: *"The store
+ is the locale boundary, so the engine's refusal is translated HERE."*
+
+### Y el alcance real, que desborda H1-C
+
+Contando las claves que **`en` y `es` tienen y `pt` no**:
+
+| prefijo | faltantes en `pt` |
+|---|---:|
+| `landing.` | 317 |
+| `cad.` | 254 |
+| `detailing.` | 158 |
+| `footing.` | 99 |
+| `loads.` | 90 |
+| `report.` | 52 |
+| `codes.` | 48 |
+| `pro.` | 33 |
+| **total** | **1176** |
+
+`pt` es uno de los tres idiomas ofrecidos. **1176 claves faltantes es un problema de proyecto, no
+de esta rama**, y no lo toco.
+
+**Hecho:** las cuatro `detailing.review.*` que faltaban, porque mi cambio las pone en pantalla en
+un archivo que poseo. 5 de 5 en los tres idiomas ofrecidos.
+
+**Recomendación:** extender `locale-parity` de `design.` a todos los namespaces, o al menos
+agregar `detailing.` y `footing.`; y agregar `lib/store` al escaneo de `pro-flow-coverage`. Las dos
+son de una línea y las dos van a fallar fuerte la primera vez. Merece su propio bloque.
+
+---
+
+## 5. Legibilidad y encaje — dos afirmaciones distintas
+
+18 nodos de texto medidos en `documents-stage`, en `en`/`es`/`pt` a 1280×720 y 1024×700:
+
+- **desborde horizontal: 0** en las seis combinaciones;
+- **contraste: ningún texto bajo su umbral** — ni uno.
+
+Es un resultado limpio y lo digo con la misma claridad que los defectos. Pero encajar no es
+informar: con un documento construido la etapa muestra readiness, revisión, madurez, cuatro
+botones y el formulario de revisión. **No muestra qué se exportó, ni la lista de planos, ni una
+vista previa.** El contenedor entra y la información es legible; lo que falta es información, y eso
+no lo detecta ninguna medición de layout.
+
+---
+
+## 6. Lo que esta auditoría NO cubrió
+
+- **Exportación incompleta y error.** No conseguí que `buildDocument` devuelva `null` en este
+ fixture, así que `doc-error` y `detailing.doc.noCoordinated` **no se ejercitaron en navegador**.
+ Hace falta un modelo sin conjuntos coordinados.
+- **DXF y XLSX como contenido.** Verifiqué que el XLSX se descarga con el nombre correcto; **no
+ abrí el archivo**. Que el DXF sea R12 válido y que el XLSX tenga las hojas esperadas son
+ aserciones que faltan.
+- **`superseded-docs`** aparece y no medí su contenido ni su orden.
+- **Foco y retorno.** El foco al abrir y cerrar el visor está fijado en H1-B. El foco **dentro** de
+ la etapa —después de exportar, después de una revisión— no se midió.
+- **Estados de carga.** No encontré ninguno: las exportaciones son sincrónicas en este fixture.
+ Con un edificio de 7 pisos podría no serlo, y no lo probé.
+- **`SheetPreview`.** El componente existe (271 líneas) y no aparece en esta etapa. No averigüé
+ desde dónde se alcanza.
+
+---
+
+## 7. Frontera de archivos compartidos
+
+| Archivo · líneas | Contrato | Dueño |
+|---|---|---|
+| `lib/store/detailing.svelte.ts:1380` | `retireDocument()` antes de validar. 14 componentes leen este store. | **Store compartido.** §3. |
+| `lib/i18n/__tests__/locale-parity.test.ts:63` | Vigila sólo `design.`. | Compartido — la extensión afecta a las dos ramas y a `edu`. |
+| `lib/i18n/__tests__/pro-flow-coverage.test.ts:50` | Escanea componentes y el motor, no los stores. | Compartido. |
+| `lib/i18n/locales/pt.ts` | 1176 claves faltantes. | Proyecto. H1 agregó 4. |
+
+`DocumentsSection.svelte` **no** es compartido: lo montan `ProRcWorkflowTab` y `DetailingWorkflow`,
+los dos de hormigón.
+
+---
+
+## 8. Cierre de Documentos — segunda pasada
+
+### `SheetPreview` está en Detallado, no en Documentos
+
+Respuesta a la pregunta que H1-C había dejado abierta: `SheetPreview` lo monta
+**`DetailingWorkflow.svelte:266`**, dentro de la etapa de **Detallado**. Sus testids son
+`sheet-figure`, `sheet-preview`, `sheet-expand`, `sheet-modal`, `sheet-empty`,
+`sheet-zoom-level`.
+
+Es decir: **la vista previa del plano vive una etapa antes que las exportaciones que lo emiten.**
+El comentario del propio montaje explica que expandir abre el mismo `detailingStore.sheetSvg` que
+llevan el DXF y el reporte, así que no hay dos renderers — pero un lector que está en Documentos
+decidiendo si exportar no tiene el plano a la vista.
+
+**No lo moví ni lo dupliqué.** Mover una vista previa de etapa es un cambio de flujo, no un
+arreglo; duplicar el componente crearía la segunda copia que su propio comentario evita.
+
+### Lo que Documentos ahora sí muestra
+
+La etapa mostraba tres estados y ningún contenido: readiness, revisión, madurez. Un lector no
+podía saber si "Revision 1" cubría un conjunto o cuarenta, contra qué reglamentos se verificó, ni
+si arrastraba supuestos. **Todo eso ya estaba en `DocumentModel`** y no se mostraba:
+
+| Nuevo | testid | De dónde sale |
+|---|---|---|
+| conjuntos | `doc-count-assemblies` | `d.assemblies.length` |
+| certificados | `doc-count-certificates` | `d.certificates.length` |
+| cláusulas invocadas | `doc-count-clauses` | `d.refs.length` |
+| supuestos *(sólo si hay)* | `doc-count-assumptions` | `d.assumptions.length` |
+| reglamentos con edición | `doc-regulations` | `d.regulations` |
+
+Ninguna cifra es nueva ni calculada: son las que el documento ya declaraba. Y **no hay ceros
+fabricados**: el bloque entero sólo se renderiza si `detailingStore.document` existe, lo que un
+test verifica en las dos direcciones — antes de exportar no hay ni una cifra, no una fila de
+ceros.
+
+Las cuatro claves nuevas van en `en`, `es` y `pt`, los tres idiomas ofrecidos.
+
+### `superseded-docs`
+
+Verificado con contenido real: una revisión registrada retira el documento actual, y la lista lo
+**conserva nombrado por revisión** (`superseded-{n}`), no lo borra. Es el mismo principio que
+`footing-document-slice.test.ts` enuncia sobre el renderer: *"A project that cannot show what it
+previously issued cannot answer the only question that matters after something goes wrong."*
+
+### Foco dentro de la etapa
+
+Medido, y es buena noticia:
+
+- después de una exportación el foco **queda en el botón que la corrió** (`doc-xlsx`), pese a que
+ el panel se re-renderiza alrededor;
+- después de una revisión registrada el foco **queda en `review-submit`**.
+
+Lo que valía la pena chequear era el caso contrario: un control que pasa a `disabled` pierde el
+foco a ``, y para un teclado eso es un callejón —el siguiente Tab reinicia desde el
+principio del documento—. **No ocurre acá.** Queda aserido para que un cambio futuro del gating no
+lo introduzca sin que nadie lo note.
+
+### Lo que sigue SIN cubrir, y por qué
+
+- **`doc-error` / `detailing.doc.noCoordinated`.** Sigue sin ejercitarse en navegador: en este
+ fixture `buildDocument` siempre devuelve un documento. Hace falta un modelo con detallado
+ generado pero **sin coordinar**, y no encontré cómo producirlo con los fixtures actuales. **No
+ inventé cobertura.**
+- **El contenido de los archivos.** El XLSX está verificado por nombre (`detailing-rev1.xlsx`);
+ **no lo abrí**. Que tenga las hojas esperadas, que el DXF sea R12 válido y que el reporte
+ imprima lo que dice son tres aserciones que faltan y que necesitan leer los blobs, no la UI.
+- **El reporte PDF.** `exportReport` usa `window.open` + `print()`. En Playwright abre un popup
+ —verificado, `popups: 1`— y no hay PDF que inspeccionar sin interceptar la impresión. Ruta
+ parcialmente cubierta y dicho como tal.
+- **Qué se exportó.** El store **no lo registra**: no hay `lastExport` ni equivalente. Mostrarlo
+ exigiría estado nuevo en el store, y no invento progreso que el store no soporte. Es la pieza
+ de información que más falta en esta etapa y la única que no se puede agregar sin decidir un
+ contrato.
+
+### Documentos NO está terminado
+
+Encaja, es legible y ahora dice qué contiene. Lo que le falta no es layout:
+
+1. no muestra el plano que va a exportar (§8, `SheetPreview` está en Detallado);
+2. no registra ni muestra qué se exportó ni cuándo;
+3. el estado de error de exportación no está ejercitado.
diff --git a/docs/handoffs/h1e-fixture-coverage.md b/docs/handoffs/h1e-fixture-coverage.md
new file mode 100644
index 000000000..1d6487b6e
--- /dev/null
+++ b/docs/handoffs/h1e-fixture-coverage.md
@@ -0,0 +1,359 @@
+# H1-E — qué produce cada fixture, y qué no produce ninguno
+
+**Rama:** `feat/pro-concrete-h1` · **PR:** [#161](https://github.com/lambdaclass/stabileo/pull/161)
+**Specs:** `e2e/h1e-conflict-states.spec.ts`, `e2e/h1d-viewer-audit.spec.ts`
+
+Medido sobre producción sin modificar. Ni el motor ni el solver se tocaron para fabricar un caso.
+
+## 1. Los tres fixtures RC
+
+| fixture | conflictos | marcadores | estados en el rail | tiempo |
+|---|---:|---:|---|---:|
+| `rc-design-qa-8` | 0 | 0 | modelled 9 | 5 s |
+| `rc-qa-diagnostic` | 68 | 68 | modelled 23 · **provisional 5** | 4 s |
+| `pro-edificio-7p` | 1318 | 1310 | modelled 194 · provisional 6 · **failed 6** | 22 s |
+
+`rc-qa-diagnostic` es el fixture de conflictos: los produce y es **más rápido** que el de 8
+elementos. `pro-edificio-7p` se usa una sola vez, para `failed`, que es el único estado que sólo
+él alcanza.
+
+## 2. Lo que quedó ejercitado en navegador
+
+- **Capa de conflictos**: 68 marcadores → 0 → 68. Cierra la anotación de H1-D.
+- **`doc-conflicts`**: estado que H1-C nunca alcanzó. Renderiza con la cuenta real y la readiness
+ cae a borrador.
+- **`provisional` y `failed`** en el rail, con sus dots comparados **por valor** contra `#a066d3` y
+ `#e0444a` de la escena, y con la palabra de estado al lado.
+
+## 3. Lo que ningún fixture alcanza
+
+### 3.1 `refused` / no armados — **cero en los tres**
+
+Aserido como cero en `h1e-conflict-states.spec.ts`, no anotado: el día que un fixture produzca
+uno, el test falla y obliga a ejercitar el filtro `rebar-hide-unreinforced` y el bloque
+`.unreinforced`.
+
+**Qué haría falta:** un miembro que el diseño rechace armar. Los tres fixtures diseñan todo lo que
+tienen. Un fixture aislado con una sección insuficiente para su demanda debería producirlo, sin
+tocar el motor.
+
+### 3.2 `doc-error` — los tres construyen documento
+
+`buildDocument` devuelve `null` cuando **no hay detallado coordinado**. Los tres fixtures coordinan.
+
+**Qué haría falta:** detallado generado **pero no coordinado**. No encontré la ruta: `generate()`
+produce conjuntos ya coordinados. Habría que ver si existe un estado intermedio alcanzable o si
+hace falta un fixture sembrado con `__stabileoActions.seedDetailing`, que ya existe y escribe
+`model.detailing` directamente — probablemente el camino más barato y sin tocar producción.
+
+### 3.3 `ConflictInspector` — inalcanzable desde un test
+
+Renderiza desde `selection.conflict`, que sólo setea `rebarWorkspace.selectConflict`, y la única
+ruta de UI es **clickear un marcador en la escena WebGL**, que se resuelve por raycasting contra
+el canvas. `e2e-hooks.ts` no expone `selectConflict`.
+
+El spec asserta que **los marcadores están** y que **el inspector no**, con el motivo, en vez de
+dejar un hueco silencioso.
+
+**Cambio mínimo propuesto:** una línea en `e2e-hooks.ts`, junto a `selectAssembly` y
+`reviewAssembly` que ya viven ahí:
+
+```ts
+selectConflict: (i: number) => {
+ const c = detailingStore.conflicts[i];
+ if (c) rebarWorkspace.selectConflict(c as never);
+},
+```
+
+Con eso el inspector queda cubierto: su banda, sus bordes espejados, sus dos niveles de
+severidad y sus botones de centrar y aislar — hoy todo verificado sólo por fuente.
+
+`e2e-hooks.ts` es superficie compartida y el hook es de mutación, así que **no lo agregué**.
+
+## 4. Estado de H1-D — ~~no cerrado~~ **superado por §6-§9**
+
+> **Esta sección quedó vieja y se conserva para que se lea la secuencia.** Los cinco puntos que
+> listaba abajo están todos cubiertos: ver §6 (sección de corte, rail angosto, familias), §7 (el
+> rechazo real y el `ConflictInspector`) y §8 (el orden de clasificación). Lo único que sigue
+> abierto de esta lista es que **ningún fixture RC produce un miembro sin armar por sí mismo** —
+> el que existe lo produce `updateSection`.
+
+Lo que decía en su momento, con este bloque recién cerrado y antes de las pasadas siguientes:
+
+- `refused` y el bloque de no armados (§3.1) — **cubierto en §7**;
+- `ConflictInspector` (§3.3) — **cubierto en §7**, vía el hook `selectConflict`;
+- la sección de corte (`rebar-section-axis`, `rebar-section-at`) — **cubierta en §6**;
+- el rail a viewport angosto, donde `rebar-rail-toggle` **sí** se muestra — a 1280×720 tiene
+ `display: none` y la caja mide 0×0 — **cubierto en §6**;
+- la visualización por familia más allá del censo — **cubierta en §6**.
+
+---
+
+## 5. Segunda pasada — los dos fixtures autorizados
+
+### 5.1 `doc-error`: **no es alcanzable**, y el motivo es estructural
+
+No hace falta un fixture. Medido con `seedDetailing([])`:
+
+ antes: documents-stage 1 · doc-xlsx 1
+ después: documents-stage 0 · documents-empty 1 · doc-xlsx 0 · doc-error 0
+
+`buildDocument` devuelve `null` en **una sola** condición —`persisted.assemblies.length === 0`— y
+`DocumentsSection` renderiza toda su etapa detrás de `{#if !selected}`, donde `selected` deriva de
+la misma lista. **La ausencia que haría fallar la construcción también quita los botones que la
+llamarían.** No hay nada que clickear.
+
+O sea que `docError = t('detailing.doc.noCoordinated')` es código defensivo para una carrera que el
+propio código ya eliminó: el comentario de `buildDocument` describe el arreglo —leer del store
+**persistido** y no de un `$derived` que "does not necessarily recompute inside the synchronous turn
+that wrote its dependency"—.
+
+**Queda aserido como inalcanzable**, con el mecanismo, en `e2e/h1e-absence-states.spec.ts`. No lo
+fabriqué: forzarlo exigiría cambiar producción para que un guard dispare.
+
+### 5.2 `refused`: **bloqueado**, y falta un hook
+
+`REFUSED` sale de un **outcome de diseño** —`SECTION_INADEQUATE` o `SEARCH_EXHAUSTED`
+(`element-status.ts:345`)—, no de un detallado sembrado. `seedDetailing` escribe
+`model.detailing`; los outcomes viven en `verificationStore`.
+
+Medido en los tres fixtures:
+
+| fixture | outcomes |
+|---|---|
+| `rc-design-qa-8` | `VERIFIED` 8 |
+| `rc-qa-diagnostic` | `VERIFIED` 22 · `PROVISIONAL_BIAXIAL` 8 |
+| `pro-edificio-7p` | `VERIFIED` 198 · `PROVISIONAL_BIAXIAL` 10 |
+
+**Ninguno rechaza.** Y no hay ruta de UI: `ProSectionsTab` no tiene un solo `data-testid`,
+`SectionChanger` tampoco, y `BatchEditDialog` edita armadura, no secciones.
+
+**Lo que falta es un mutador de test**, exactamente como `selectConflict`:
+
+```ts
+updateSection: (id: number, data: unknown) => { modelStore.updateSection(id, data as never); },
+```
+
+Con eso el fixture es: cargar `rc-design-qa-8`, achicar una viga hasta que su sección no alcance,
+`designAll()`, y el motor **rechaza de verdad** — no se fabrica el estado, se produce.
+
+No lo agregué: el bloque autorizaba usar hooks existentes, y éste no existe. Es una línea y va con
+el mismo criterio con que se aprobó `selectConflict`.
+
+### 5.3 Las tres ausencias que sí se distinguen
+
+`h1e-absence-states.spec.ts`, en los tres idiomas:
+
+| estado | testid | qué significa |
+|---|---|---|
+| sin detallado | `documents-empty` | no hay nada que documentar · **ningún export ofrecido** |
+| sin documento | `doc-none` | hay detallado, no se construyó documento · **los exports SÍ se ofrecen** |
+| familias vacías | `rebar-empty-families` | esas familias no tienen geometría — y **no** `rebar-workspace-empty`, que diría que no hay nada |
+
+Aserido que las dos primeras **no comparten frase**. Es el mismo defecto que tenían las familias de
+pisos: "miramos y no hay" impreso igual que "nadie miró".
+
+Y la regla de VERIFIED: una etapa vacía no dice `verified`, `issued` ni `constructible`, y los
+elementos que llevarían veredicto —`doc-readiness`, `doc-maturity`, `doc-contents`,
+`review-record`, `doc-revision`, `issue-submit`— **no existen**, que es más fuerte que un texto
+prudente.
+
+## 6. H1-E: los tres que faltaban
+
+`h1e-rail-and-section.spec.ts`, 9 casos.
+
+**Sección de corte.** Es un **plano de clipping** —`renderer.localClippingEnabled = true`—, no un
+filtro: no se quita ninguna malla y el censo no se mueve. Mi primera versión asertó el censo y
+falló en los tres ejes; el instrumento estaba mal, no la función. Lo observable desde el DOM es el
+control dependiente: elegir eje trae el deslizador de posición y elegir ninguno lo saca. Los
+límites salen de la escena (~5,4 m en y), no de un 0..1 fijo.
+
+**Rail angosto.** A 820 el toggle aparece —a 1280 mide 0×0—, colapsa el rail y lo devuelve, mueve
+`aria-expanded` (su único contenido accesible: es un glifo sin etiqueta) y conserva el foco. El
+canvas mantiene ancho > 400 px, que es la razón por la que el rail pasa a ser una lámina encima.
+Abierto **a** 820 y no redimensionado hacia ahí: `onResize` pone `railOpen = wide` al cruzar 860, y
+mi primera versión corría contra ese handler.
+
+**Familias.** Cada familia con geometría se apaga y se vuelve a prender por separado, verificado
+contra el censo. Las 5 familias vacías **se nombran** en vez de desaparecer (4 de 5 aparecen por
+nombre en el texto). Y el tally reporta por familia —sólidos, longitudinal, transversal— con el
+total de barras coincidiendo con el censo; asertar el conteo del censo contra las celdas del tally
+era leer un número esperando otro.
+
+---
+
+## 7. Tercera pasada — el fixture de rechazo, y lo que destapó
+
+### 7.1 El fixture
+
+`__stabileoActions.updateSection` —mutador de test, junto a `selectConflict`, `selectAssembly` y
+`reviewAssembly`— achica **una** sección y el diseño vuelve a correr:
+
+ rc-design-qa-8, sección 2 (`RC Col 400×400`) → 90 × 120 mm
+ → SEARCH_EXHAUSTED ×8 · VERIFIED ×4
+
+**Rechazo real del motor**, no un estado escrito. El buscador enumera todo el envolvente permitido
+por norma para una columna que no puede con su demanda, no encuentra nada que verifique, y lo
+dice — que es la distinción honesta que `candidate-search.ts` documenta entre "agotado" e
+"inviable".
+
+Dos cosas que costaron encontrar y conviene no repetir:
+
+- **Sección 2, no la 1.** La 1 no la usan los miembros diseñados; achicarla no cambia nada.
+- **90 × 120 mm, no 50 × 60.** Achicar las ocho secciones llevó el diseño más allá de diez
+ minutos: cuando *nada* entra, el buscador enumera un envolvente mucho mayor.
+
+### 7.2 Lo que destapó: `REFUSED` está tapado por `FAILED`
+
+El outcome **es** `SEARCH_EXHAUSTED` y `element-status.ts:345` lo mapea a `REFUSED`. **El rail
+nunca lo muestra.**
+
+`element-status.ts:316` chequea `verificationStatus === 'fail'` **primero**, y un miembro cuyo
+diseño fue rechazado también falla verificación — el rechazo ocurrió justamente porque nada en el
+envolvente verificaba. Así que `FAILED` gana y la rama `REFUSED` no se alcanza.
+
+Medido:
+
+ rail: failed 5 · refused 0 · modelled 5
+ censo: column bars 0 · column solids 4 · markers 20
+
+Que `FAILED` vaya primero **es correcto** para el caso que su propio comentario describe: un
+miembro con un `VERIFIED` viejo que hoy falla. Pero se traga la distinción que los estados existen
+para hacer. El encabezado del mismo archivo enuncia los remedios:
+
+> `- the design was refused → change the section, or design by hand`
+
+y un `FAILED` manda al lector a cambiar la armadura, que es el arreglo equivocado.
+
+**No lo toqué.** Reordenar un clasificador cambia cómo se llama **cada** miembro de la app, y eso
+es una decisión, no un arreglo de paso. El test lo fija **como es** —`failed > 0` y `refused === 0`
+con el motivo escrito— así que el día que se reordene, falla y hay que actualizarlo a propósito.
+
+### 7.3 Lo que sí quedó ejercitado
+
+- La columna rechazada **pierde el acero y conserva el hormigón**: censo de barras de columna
+ 200 → **0**, sólidos 4. Es la consecuencia visible del rechazo.
+- El bloque `.unreinforced` **aparece y explica** (>20 caracteres), en el workspace. H1-D lo había
+ aserido ausente con la premisa `refused === 0`; ésta es la otra cara, y la razón por la que se
+ escribió como premisa y no como anotación.
+- Ningún miembro rechazado dice `verified` ni `certified` en su fila.
+- Los conteos separan rechazo, fallo y verificado: `{"SEARCH_EXHAUSTED":8,"VERIFIED":4}`.
+
+### 7.4 `doc-error` sigue siendo inalcanzable — la cadena completa
+
+Confirmado otra vez, y vale escribir la cadena entera porque es lo que pide no volver a intentarlo:
+
+1. `buildDocument` devuelve `null` **sólo** si `persisted.assemblies.length === 0`
+ (`detailing.svelte.ts:1479`).
+2. `DocumentsSection` renderiza **toda** su etapa detrás de `{#if !selected}` (línea 191), y
+ `selected` es `detailingStore.selected`, derivado de la misma lista de conjuntos.
+3. Con cero conjuntos, `selected` es `null` → sale la rama `documents-empty`.
+4. Los cuatro botones —`doc-report`, `doc-dxf`, `doc-xlsx`, `doc-3d`— viven **dentro** de la rama
+ contraria, así que dejan de existir.
+5. `currentDoc()` es el único llamador de `buildDocument`, y sólo lo llaman esos cuatro.
+
+**El paso 4 elimina los botones antes de que el paso 5 pueda ocurrir.** No hay orden de eventos que
+deje `selected` no nulo con la lista vacía: es la misma lista.
+
+Medido: `documents-stage 0 · documents-empty 1 · doc-xlsx 0 · doc-error 0`.
+
+`detailing.doc.noCoordinated` queda como guarda defensiva de una carrera que el propio código
+eliminó al leer del store **persistido** en vez de un `$derived`. El test de inalcanzabilidad se
+mantiene.
+
+---
+
+## 8. `REFUSED` visible — el orden de clasificación corregido
+
+### El cambio
+
+`element-status.ts`, un `if`:
+
+```diff
+- if (summary?.verificationStatus === 'fail' && !isKnownBiaxialLimitation(summary)) {
++ const EXPLAINS_ITS_OWN_FAILURE = ['SEARCH_EXHAUSTED', 'SECTION_INADEQUATE', 'UNSUPPORTED'];
++ const outcomeExplainsIt = summary?.outcome !== undefined
++ && EXPLAINS_ITS_OWN_FAILURE.includes(summary.outcome);
++
++ if (summary?.verificationStatus === 'fail'
++ && !isKnownBiaxialLimitation(summary)
++ && !outcomeExplainsIt) {
+ return 'FAILED';
+ }
+```
+
+**`FAILED` conserva la prioridad exactamente para el caso que describe su comentario** — un
+resultado con outcome `VERIFIED` que hoy falla, o un miembro sin outcome donde no hay nada más
+que decir. Los tres outcomes que **ya nombran el motivo** caen al `switch` y se llaman como lo que
+son.
+
+### Un error propio que atraparon los tests existentes
+
+La primera versión decía `summary.outcome !== 'VERIFIED'`, o sea "todo menos verificado". Eso
+rompió la **excepción biaxial estrecha**: una propuesta `PROVISIONAL_BIAXIAL` que además falla en
+flexión volvía a `PROVISIONAL` en vez de `FAILED`.
+
+Dos tests preexistentes lo cazaron —`element-status.test.ts` y
+`provisional-presentation.test.ts`— y ése es el argumento de la lista explícita:
+`PROVISIONAL_BIAXIAL` **no** explica un fallo de flexión, y `DEMAND_UNAVAILABLE` tampoco explica
+un fallo de verificación.
+
+### Semántica, antes y después
+
+| outcome | verificación | antes | ahora |
+|---|---|---|---|
+| `SEARCH_EXHAUSTED` | falla | FAILED | **REFUSED** |
+| `SECTION_INADEQUATE` | falla | FAILED | **REFUSED** |
+| `UNSUPPORTED` | falla | FAILED | **UNSUPPORTED** |
+| `VERIFIED` | falla | FAILED | FAILED *(sin cambio)* |
+| sin outcome | falla | FAILED | FAILED *(sin cambio)* |
+| `PROVISIONAL_BIAXIAL`, sólo biaxial | falla | PROVISIONAL | PROVISIONAL *(sin cambio)* |
+| `PROVISIONAL_BIAXIAL`, además flexión | falla | FAILED | FAILED *(sin cambio)* |
+
+**Los cuatro estados metálicos sin VERIFIED se conservan.** Los dos lados del cambio siguen en
+`NOT_FOR_CONSTRUCTION_STATUSES` —la lista que consumen la leyenda del visor, las láminas, la
+planilla y el reporte— así que ningún camino puede hacer que un rechazo se lea como trabajo
+terminado. Aserido en el test.
+
+### Consumidores revisados
+
+- `RebarStatusPanel` — `.st-failed` (`#e0444a`) y `.st-refused` (`#d4762a`): los dos ya existían
+ y los dos espejan la escena. **Ningún cambio de CSS.**
+- `ELEMENT_STATUS_ORDER` y `NOT_FOR_CONSTRUCTION_STATUSES` — ya contenían los dos estados.
+- Los otros `'FAILED'` del árbol son **enums distintos**: `family-record.ts` (estado de
+ certificado de familia) y `rc-cad-handoff-v2-types.ts` (handoff CAD). No los toca.
+
+### Idiomas y badges
+
+`detailing.scene.status.REFUSED` y `.FAILED` **ya existían en los tres idiomas ofrecidos**:
+Refused / Rechazado / Recusado, y Failed / Falla / Falha. No hizo falta ninguna clave nueva, y
+`locale-parity` y `pro-flow-coverage` siguen verdes.
+
+### Verificación
+
+Los tres tests de rechazo **fallaban** con el orden anterior (`expected 'FAILED' to be 'REFUSED'`)
+y pasan con el nuevo; los cinco de regresión pasaban antes y siguen pasando. El fixture
+`h1e-refused-state` pasó de asertar `refused === 0` a asertar que el rail **dice REFUSED** con su
+palabra y con el naranja `#d4762a` de la escena — no el rojo de conflicto.
+
+## 9. Estado final de la cobertura de H1-E
+
+**Cubierto en navegador:** rechazo real y visible como tal, conflictos y `ConflictInspector`
+completo, ausencias distinguidas en tres idiomas, sección de corte, rail angosto, familias, foco,
+`Escape` y retorno.
+
+**Documentado como inalcanzable, con la cadena completa:** `doc-error` (§7.4) — el paso que
+elimina los botones ocurre antes de que el guard pueda dispararse, y no hay orden de eventos que
+lo evite porque es la misma lista.
+
+**Ausente de los fixtures actuales:** ningún ejemplo RC produce un miembro sin armar *por sí
+mismo*; el que existe ahora lo produce el fixture de `updateSection`.
+
+**`updateSection` es mecanismo exclusivo de test.** Vive sólo en `e2e-hooks.ts`, junto a
+`selectConflict`, `selectAssembly` y `reviewAssembly`. Nada de la aplicación lo llama. Cambia una
+dimensión y nada más: el rechazo que sigue es del motor real sobre una sección que genuinamente no
+alcanza.
+
+**Limitaciones que quedan:** el contenido de los archivos exportados (XLSX verificado por nombre,
+DXF no verificado, el reporte abre ventana sin PDF inspeccionable), y el registro de exportaciones,
+que necesita el contrato de `h1-export-coverage-and-contract.md`.
diff --git a/docs/handoffs/i18n-coverage-gap.md b/docs/handoffs/i18n-coverage-gap.md
new file mode 100644
index 000000000..497769fd1
--- /dev/null
+++ b/docs/handoffs/i18n-coverage-gap.md
@@ -0,0 +1,145 @@
+# Huecos de traducción, y por qué los gates no los ven
+
+**Origen:** H1-C (`feat/pro-concrete-h1`, [PR #161](https://github.com/lambdaclass/stabileo/pull/161)).
+**Estado: reporte. Nada cambiado por este documento.**
+Encontrado de rebote: un cambio de H1 puso en pantalla un mensaje que en portugués no existía.
+
+Esto **no** es parte de H1-C. Lo separo justamente para que no se convierta en su alcance
+accidental.
+
+---
+
+## 1. Cómo apareció
+
+`DocumentsSection` ahora dice los motivos de rechazo de una revisión **antes** del click. El
+tripwire de idioma del spec falló:
+
+ pt · review-blockers → "The reviewing engineer must be named. There are provisional…"
+
+Las cinco claves `detailing.review.*` existían en `en` y `es`. En `pt` había **una de cinco**. Un
+usuario en portugués era rechazado en inglés.
+
+Las cuatro se agregaron en el commit de H1-C, porque ese cambio es el que las pone en pantalla en
+un archivo que H1 posee. **El resto no se tocó.**
+
+---
+
+## 2. Alcance
+
+14 diccionarios en `src/lib/i18n/locales/`: `ar de en es fr hi id it ja ko pt ru tr zh`.
+
+Tomando como base las claves que **`en` y `es` tienen las dos** (3 197 + las propias de cada uno):
+
+| diccionario | claves faltantes | claves que tiene |
+|---|---:|---:|
+| **`pt`** | **1 172** | — |
+| `de` | 2 565 | 3 202 |
+| `ar` `fr` `hi` `id` `it` `ja` `ko` `ru` `tr` `zh` | 2 570 cada uno | 3 197 cada uno |
+
+`pt` es el caso que importa **porque es uno de los tres idiomas ofrecidos**. Los otros once están
+esencialmente sin traducir más allá de un núcleo común, lo cual es coherente con que no se
+ofrezcan — pero conviene que sea una decisión escrita y no un hallazgo.
+
+### `pt`, por namespace
+
+| namespace | faltantes | | namespace | faltantes |
+|---|---:|---|---|---:|
+| `landing.` | 317 | | `ribbon.` | 26 |
+| `cad.` | 254 | | `ex.` | 18 |
+| `detailing.` | 154 | | `geotechnical.` | 14 |
+| `footing.` | 99 | | `perf.` | 14 |
+| `loads.` | 90 | | `ai.` | 10 |
+| `report.` | 52 | | `cat.` `loadPlan.` `regulations.` | 7 c/u |
+| `codes.` | 48 | | `autoLoad.` | 5 |
+| `pro.` | 33 | | resto (`kin` `maturity` `advHelp` `edu` `slab` `wall` `proProject` `proRibbon`) | 16 |
+
+**`landing.` + `cad.` son la mitad** (571 de 1 172). Los tres namespaces que un ingeniero ve
+trabajando —`detailing.`, `footing.`, `loads.`— suman **343**.
+
+---
+
+## 3. Por qué ningún gate lo detecta
+
+Dos causas, las dos mecánicas y las dos de una línea.
+
+### 3.1 `locale-parity` sólo vigila `design.`
+
+`web/src/lib/i18n/__tests__/locale-parity.test.ts:63`
+
+```ts
+.filter((k) => k.startsWith('design.'))
+```
+
+El test **sí** compara los 14 diccionarios, y por eso `design.*` está completo en todos —cuando
+agregué 31 claves de `design.` en un bloque anterior, este gate me obligó a ponerlas en los 14—.
+Todo lo demás está fuera de su alcance. `detailing.`, `footing.`, `loads.`, `report.`, `codes.`
+nunca se compararon.
+
+### 3.2 `pro-flow-coverage` no escanea los stores
+
+`web/src/lib/i18n/__tests__/pro-flow-coverage.test.ts:50`
+
+Escanea componentes y `lib/engine/detailing`. Las claves de esta familia las emite el **store**, y
+el store lo dice de sí mismo:
+
+> *"The store is the locale boundary, so the engine's refusal is translated HERE. It used to
+> arrive as a Spanish sentence built inside a pure module, which told an English-locale user why
+> their review was refused in the wrong language."*
+
+Es decir: mover la traducción al store fue **correcto** —arregló un defecto real— y dejó las
+claves en una capa que el gate no mira. El arreglo de un problema creó el punto ciego del otro.
+
+---
+
+## 4. Propuesta
+
+### Paso 1 — medir con el gate antes de traducir nada
+
+Ampliar `locale-parity` con un **mapa de namespaces vigilados**, no con un salto a "todos". Un
+`.filter()` que abarque todo va a fallar con 2 570 entradas por diccionario y no se va a poder
+leer.
+
+```ts
+// Vigilados en los 14 diccionarios: lo que un ingeniero ve trabajando.
+const GUARDED = ['design.', 'detailing.', 'footing.', 'loads.', 'codes.'];
+// Vigilados sólo en los idiomas OFRECIDOS: el resto de la app.
+const OFFERED_ONLY = ['report.', 'cad.', 'pro.', 'ribbon.', 'regulations.'];
+// Explícitamente fuera: `landing.` tiene su propio ciclo de traducción.
+```
+
+Ese recorte convierte 1 172 en dos números manejables y hace que la lista sea una decisión escrita
+en vez de un `startsWith` heredado.
+
+### Paso 2 — agregar `lib/store` al escaneo de `pro-flow-coverage`
+
+Una línea en la lista de `:50`. Va a fallar la primera vez, y lo que encuentre es exactamente la
+clase de hueco que este documento describe: claves que un componente muestra y que se emiten una
+capa más abajo.
+
+### Paso 3 — traducir por namespace, con el gate ya puesto
+
+En orden de lo que un usuario en portugués ve primero:
+
+1. `detailing.` 154 · `footing.` 99 · `loads.` 90 — la superficie de trabajo. **343.**
+2. `codes.` 48 · `report.` 52 · `pro.` 33 · `ribbon.` 26 — chrome y salidas. **159.**
+3. `cad.` 254 — un bloque propio; es vocabulario de dibujo y merece revisión de un CAD.
+4. `landing.` 317 — ciclo de marketing, no de producto.
+
+### Paso 4 — decidir los once no ofrecidos
+
+`ar de fr hi id it ja ko ru tr zh` están a ~2 570 claves cada uno. Las opciones son declararlos
+explícitamente como no soportados —y que el gate no los mire— o sacarlos del repo hasta que haya
+un plan. Hoy son 11 archivos que parecen soporte y no lo son.
+
+---
+
+## 5. Archivos compartidos
+
+| Archivo | Dueño |
+|---|---|
+| `lib/i18n/__tests__/locale-parity.test.ts:63` | **Compartido.** Ampliar el filtro afecta a H1, M1, `edu` y la landing a la vez. |
+| `lib/i18n/__tests__/pro-flow-coverage.test.ts:50` | **Compartido.** |
+| `lib/i18n/locales/*.ts` | Proyecto. H1 agregó 4 claves de `detailing.review.*` y 4 de `detailing.doc.contents.*`, en `en`/`es`/`pt`. |
+
+Ninguno de los cuatro pasos es de H1 por su cuenta. El paso 1 y el 2 hay que hacerlos **antes**
+del 3, o la traducción avanza sin nada que la mida.
diff --git a/web/e2e/concrete-copy-contrast.spec.ts b/web/e2e/concrete-copy-contrast.spec.ts
new file mode 100644
index 000000000..6a37cfc92
--- /dev/null
+++ b/web/e2e/concrete-copy-contrast.spec.ts
@@ -0,0 +1,219 @@
+/**
+ * The concrete surface's secondary copy clears AA, measured on the ground it actually sits on.
+ *
+ * ── The migration this pins ────────────────────────────────────────
+ *
+ * `--st-text-3` (`#64798a`) clears 4.5:1 on none of the four opaque grounds a panel can have:
+ * 4.03 / 3.74 / 3.62 / 3.28. It cannot, either — preserving its hue, the first lightness that
+ * does is 58 % while `--st-text-2` sits at 63 %, so a legal third level lands five points from
+ * the second and stops being a level.
+ *
+ * Path C, approved: the token keeps its value and its meaning narrows to inactive and disabled
+ * text, glyphs and rules. The 25 sites in the concrete design surface that were carrying COPY
+ * moved to `--st-text-2`. The 464 elsewhere did not, and are listed in
+ * `docs/handoffs/h1-text-3-contrast-proposal.md`.
+ *
+ * ── Why this measures rather than reads the source ─────────────────
+ *
+ * A source test can prove the declaration says `--st-text-2`. It cannot prove the element is
+ * legible, because the ratio depends on the ground — and the ground is whatever ancestor
+ * happens to paint a background. `--st-text-2` is 6.49 on `--st-surface` and 5.70 on
+ * `--st-surface-3`; both pass, but a future well or card could put copy somewhere neither of
+ * those numbers covers. So the ratio is computed in the page, against the resolved background of
+ * the nearest opaque ancestor.
+ *
+ * Three languages because the elements that RENDER differ per locale — a Portuguese state name
+ * can wrap onto a row an English one does not reach — so the set of measured nodes is not the
+ * same set.
+ */
+
+import { test, expect, designAll, loadModel } from './fixtures';
+import type { Page } from '@playwright/test';
+
+/** Every text node in the subtree, with its computed ratio against its real ground. */
+const AUDIT = `(rootSel) => {
+ const root = document.querySelector(rootSel);
+ if (!root) return null;
+
+ const lum = (c) => {
+ const m = c.match(/\\d+(\\.\\d+)?/g); if (!m) return null;
+ const f = m.slice(0, 3).map(Number).map((v) => v / 255)
+ .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
+ return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
+ };
+ const groundOf = (el) => {
+ for (let n = el; n; n = n.parentElement) {
+ const bg = getComputedStyle(n).backgroundColor;
+ const a = bg.match(/rgba?\\([^)]*?([\\d.]+)\\)/);
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && (!a || Number(a[1]) > 0.5)) return bg;
+ }
+ return getComputedStyle(document.body).backgroundColor;
+ };
+
+ const out = [];
+ for (const el of [root, ...root.querySelectorAll('*')]) {
+ if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml') continue;
+ if (el.classList.contains('sr-only') || el.closest('.sr-only')) continue;
+ const hasOwnText = [...el.childNodes]
+ .some((n) => n.nodeType === 3 && n.textContent.trim().length > 1);
+ if (!hasOwnText) continue;
+ const cs = getComputedStyle(el);
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
+ const px = parseFloat(cs.fontSize);
+ const bold = Number(cs.fontWeight) >= 700;
+ const large = px >= 24 || (px >= 18.66 && bold);
+ const a = lum(cs.color), b = lum(groundOf(el));
+ if (a === null || b === null) continue;
+ const [x, y] = a > b ? [a, b] : [b, a];
+ const ratio = (x + 0.05) / (y + 0.05);
+ out.push({
+ cls: (el.className || '').toString().split(' ')[0],
+ testid: el.getAttribute('data-testid'),
+ px: Math.round(px * 10) / 10,
+ ratio: Math.round(ratio * 100) / 100,
+ need: large ? 3 : 4.5,
+ fg: cs.color, ground: groundOf(el),
+ text: (el.textContent || '').trim().slice(0, 28),
+ });
+ }
+ return out;
+}`;
+
+const audit = (page: Page, sel: string) =>
+ page.evaluate(new Function('return ' + AUDIT)() as never, sel) as Promise | null>;
+
+/**
+ * The glyph the migration deliberately LEFT on `--st-text-3`.
+ *
+ * `DesignTable`'s `.caret` is a disclosure triangle, not a sentence: §1.4.11 asks 3:1 of it and
+ * it measures 3.74. Excluded by name rather than by a blanket "ignore anything under 4.5", so
+ * the exclusion is a decision on the record and a NEW under-AA element still fails.
+ */
+const ALLOWED_BELOW_AA = new Set([
+ // A disclosure triangle, not a sentence: §1.4.11 asks 3:1 and it measures 3.74.
+ 'caret',
+ /*
+ * `DesignOverview`'s census glyph and label, at 3.74 — they inherit `--st-text-3` from
+ * `.tone-muted` on line 286 of a file H1 does not own: `DesignOverview` is shared PRO chrome
+ * and M1 renders its metallic census through it.
+ *
+ * Listed by name with the reason, and NOT excluded by a blanket "ignore anything under 4.5",
+ * so the exemption is on the record and a new under-AA element still fails this gate. The fix
+ * is in `docs/handoffs/h1-shared-chrome-proposal.md`.
+ */
+ 'glyph', 'label',
+ /*
+ * `DesignToolbar`'s command-group labels, at 3.74 — `--st-text-3` on line 341 of the PRO
+ * command row, which M1's metallic commands share. Same proposal, same reason.
+ */
+ 'group-label',
+]);
+
+async function design(page: Page) {
+ await loadModel(page, 'rc-design-qa-8');
+ await designAll(page);
+ await page.getByTestId('pr-stage-design').click();
+ await page.getByTestId('pr-cmd-design').click();
+ // The regulations panel holds nine of the migrated sites, and it is the one this pass
+ // prioritised. Open it if it is behind a disclosure.
+ const regs = page.getByTestId('regs-disclosure');
+ if (await regs.count() && await regs.getAttribute('open') === null) {
+ await regs.locator('> summary').click();
+ }
+}
+
+for (const locale of ['en', 'es', 'pt'] as const) {
+ for (const [w, h] of [[1280, 720], [1024, 700]] as const) {
+ test.describe(`@slow copy contrast in ${locale} at ${w}×${h}`, () => {
+ test.slow();
+ test.use({ appLocale: locale, viewport: { width: w, height: h } });
+
+ test('no copy in the design panel sits under AA', async ({ pro: page }) => {
+ await design(page);
+ const found = await audit(page, '.pro-panel');
+ expect(found, 'the panel must be on screen').not.toBeNull();
+ expect(found!.length, 'text was actually measured').toBeGreaterThan(20);
+
+ const under = found!
+ .filter((f) => f.ratio < f.need)
+ .filter((f) => !ALLOWED_BELOW_AA.has(f.cls))
+ .map((f) => `${f.ratio} (need ${f.need}) .${f.cls} ${f.px}px `
+ + `${f.fg} on ${f.ground} — "${f.text}"`);
+
+ expect(under, 'copy under its contrast bar').toEqual([]);
+ test.info().annotations.push({
+ type: 'coverage',
+ description: `${found!.length} text nodes measured, `
+ + `min ratio ${Math.min(...found!.map((f) => f.ratio))}`,
+ });
+ });
+ });
+ }
+}
+
+test.describe('@slow the migrated selectors, by name', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the ones that render resolve to --st-text-2, not to --st-text-3',
+ async ({ pro: page }) => {
+ await design(page);
+ const [t2, t3] = await Promise.all([
+ page.evaluate(() => getComputedStyle(document.documentElement)
+ .getPropertyValue('--st-text-2').trim()),
+ page.evaluate(() => getComputedStyle(document.documentElement)
+ .getPropertyValue('--st-text-3').trim()),
+ ]);
+ const resolve = (c: string) => page.evaluate((v) => {
+ const el = document.createElement('span');
+ el.style.color = v; document.body.appendChild(el);
+ const out = getComputedStyle(el).color; el.remove(); return out;
+ }, c);
+ const [want, gone] = [await resolve(t2), await resolve(t3)];
+ expect(want, 'the two tokens are distinguishable on this page').not.toBe(gone);
+
+ /*
+ * Named selectors, so a failure says WHICH one regressed. Conditional per selector because
+ * which of them renders depends on the model's state — and annotated, so a run that
+ * measured three of eleven does not read as a run that measured all eleven.
+ */
+ const SELECTORS = [
+ '.role-purpose', '.census', '.hint', '.lbl', '.refused',
+ '.fam-scope dt', '.no-n', '.muted', '.desc', '.sub', '.dim',
+ ];
+ const seen: string[] = [];
+ for (const sel of SELECTORS) {
+ const el = page.locator(`.pro-panel ${sel}`).first();
+ if (!(await el.count())) continue;
+ seen.push(sel);
+ expect(await el.evaluate((n) => getComputedStyle(n).color), `${sel} migrated`)
+ .toBe(want);
+ }
+ expect(seen.length, 'at least some migrated selectors are on screen')
+ .toBeGreaterThan(0);
+ test.info().annotations.push(
+ { type: 'coverage', description: `measured: ${seen.join(' ')}` });
+ });
+
+ test('and the caret is still the inactive token, on purpose', async ({ pro: page }) => {
+ await design(page);
+ const caret = page.locator('.pro-panel .caret').first();
+ if (!(await caret.count())) {
+ test.info().annotations.push(
+ { type: 'coverage', description: 'no caret on screen — asserted at source only' });
+ return;
+ }
+ const t3 = await page.evaluate(() => getComputedStyle(document.documentElement)
+ .getPropertyValue('--st-text-3').trim());
+ const resolved = await page.evaluate((v) => {
+ const el = document.createElement('span');
+ el.style.color = v; document.body.appendChild(el);
+ const out = getComputedStyle(el).color; el.remove(); return out;
+ }, t3);
+ expect(await caret.evaluate((n) => getComputedStyle(n).color),
+ 'a glyph keeps the token the migration narrowed it to').toBe(resolved);
+ });
+});
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/e2e/detailing.spec.ts b/web/e2e/detailing.spec.ts
index 08120b326..9aba4cc8e 100644
--- a/web/e2e/detailing.spec.ts
+++ b/web/e2e/detailing.spec.ts
@@ -218,12 +218,45 @@ test.describe('@smoke inspecting generated reinforcement', () => {
// ─── The review gate ─────────────────────────────────────────────
test.describe('@smoke the review gate on generated work', () => {
- test('D7 — a review needs a named engineer', async ({ pro: page }) => {
+ test('D7 — a review needs a named engineer, and says so before you press', async (
+ { pro: page },
+ ) => {
await generateDetailing(page);
await firstAssembly(page).click();
await openDocuments(page);
- await page.getByTestId('review-submit').click();
- await expect(page.getByTestId('review-error')).toBeVisible();
+
+ /*
+ * This used to click `review-submit` unnamed and assert a `review-error` came back. That
+ * journey is no longer possible, and the change is deliberate: this branch disabled the
+ * button until there is an engineer and the provisional calculations are acknowledged, and
+ * moved the reasons NEXT TO it as `review-blockers`. `h1-manual-qa.md` states the contract —
+ * "the button is disabled until you put your name in and accept the provisional
+ * calculations, and the reasons are written beside it. If the button is grey with no
+ * explanation, THAT is a bug."
+ *
+ * So the assertion moves from "clicking it errors" to "it refuses in advance and names what
+ * is missing", which is the stronger claim: a reason you read before pressing beats an error
+ * you get after. Nothing in production changed to make this pass — the test was measuring a
+ * control that had already moved.
+ */
+ const submit = page.getByTestId('review-submit');
+ await expect(submit, 'unnamed, it refuses in advance').toBeDisabled();
+
+ const blockers = page.getByTestId('review-blockers');
+ await expect(blockers, 'and it is never grey without an explanation').toBeVisible();
+ expect((await blockers.innerText()).trim().length,
+ 'the reasons are written, not implied').toBeGreaterThan(10);
+
+ // Naming the engineer removes that reason. Whether the button becomes usable depends on the
+ // provisional acknowledgements too, which D9 covers; what matters here is that the blocker
+ // list responds to the field it names.
+ const before = (await blockers.innerText()).trim();
+ await page.getByTestId('review-engineer').fill('Ing. Bauti');
+ await expect
+ .poll(async () => (await blockers.count()) === 0
+ || (await blockers.innerText()).trim() !== before,
+ { message: 'naming the engineer changes what is missing' })
+ .toBe(true);
});
test('D9 — the review gate refuses while the assembly is below CONSTRUCTIBLE, and says why',
diff --git a/web/e2e/floor-design.spec.ts b/web/e2e/floor-design.spec.ts
index 3a0649b48..78ff85d5b 100644
--- a/web/e2e/floor-design.spec.ts
+++ b/web/e2e/floor-design.spec.ts
@@ -144,10 +144,16 @@ test.describe('@smoke unsupported conditions by family', () => {
await openPanel(page);
await expect(page.getByTestId('unsupported-list')).toContainText('INPRES-CIRSOC 103 Parte II');
+ /*
+ * "Blocks the review" is asserted where the block now lives: on the disabled control and the
+ * reasons written beside it. See the note in F10 — the click-then-error journey was removed
+ * when the refusal moved in front of the button, and this test was not moved with it.
+ */
await openDocuments(page);
await page.getByTestId('review-engineer').fill('Ing. R. Pérez');
- await page.getByTestId('review-submit').click();
- await expect(page.getByTestId('review-error')).toBeVisible();
+ await expect(page.getByTestId('review-submit')).toBeDisabled();
+ // The floor is COORDINATED, one rank below constructible, and the blocker says so by name.
+ await expect(page.getByTestId('review-blockers')).toContainText('COORDINATED');
});
test('F7 — an unsupported foundation type produces no numbers to mistake for a check', async ({ pro: page }) => {
@@ -200,19 +206,31 @@ test.describe('@smoke floor conflicts and review', () => {
await seedInto(page, [floor()]);
await openPanel(page);
- // Provisional, so a bare review is refused.
+ /*
+ * Provisional, so the review is refused — BEFORE the click, not after it.
+ *
+ * This used to fill the engineer, press `review-submit` and assert a `review-error` came
+ * back. That journey no longer exists: this branch disabled the button until there is an
+ * engineer and the provisional calculations are acknowledged, and moved the reasons next to
+ * it as `review-blockers`. The production change was correct and these tests were simply
+ * left behind it, measuring a control that can no longer be clicked.
+ */
await openDocuments(page);
await page.getByTestId('review-engineer').fill('Ing. R. Pérez');
- await page.getByTestId('review-submit').click();
+
// In ENGLISH, because this spec runs in the default `en` locale. It used to assert the
// Spanish word "provisorios" and pass — which is the proof that the refusal was a Spanish
// literal built inside a pure module and shown to an English-locale user unchanged.
- const error = page.getByTestId('review-error');
- await expect(error).toContainText('provisional calculations without express acceptance');
+ await expect(page.getByTestId('review-submit')).toBeDisabled();
+ const blockers = page.getByTestId('review-blockers');
+ await expect(blockers).toContainText('provisional');
// And it names WHICH one, so the refusal is actionable.
- await expect(error).toContainText('assembly');
+ await expect(blockers).toContainText('assembly');
+ // The acknowledgement is what the refusal asked for, so the control opens — the other half
+ // of the gate, and the reason this is not merely an assertion that a button is grey.
await page.getByTestId('ack-assembly').check();
+ await expect(page.getByTestId('review-submit')).toBeEnabled();
await page.getByTestId('review-submit').click();
await expect(page.getByTestId('review-error')).toBeHidden();
await expect(page.getByTestId('assembly-state')).toContainText('Reviewed');
diff --git a/web/e2e/floor-families-document.spec.ts b/web/e2e/floor-families-document.spec.ts
index 1b386f5d8..97f7ca403 100644
--- a/web/e2e/floor-families-document.spec.ts
+++ b/web/e2e/floor-families-document.spec.ts
@@ -262,6 +262,21 @@ test.describe('@smoke the floor-family deliverable, end to end', () => {
* review gate is live in the browser, names its own reason, and cannot be walked past by
* filling in a name. A test that engineered the fixture into CONSTRUCTIBLE would be testing a
* floor nobody has.
+ *
+ * ── Where the refusal is read, and why it moved ────────────────────
+ *
+ * This test used to CLICK `review-submit` and read `review-error`. The button is disabled
+ * whenever `reviewBlockers` is non-empty, so the click could never land and the test spent its
+ * whole 60 s timeout retrying it.
+ *
+ * The control was not weakened; the refusal was moved EARLIER. `reviewBlockers` states the
+ * same three refusals `assembly.ts` raises, from the same locale keys, before the click rather
+ * than after it — the principle this panel already applied to `issue-submit`. A refusal an
+ * engineer reads only by pressing a button is a refusal they discover by trying to sign off.
+ *
+ * So the assertions are unchanged in substance and stronger in one respect: the gate must be
+ * unwalkable AND say so with the name filled in, which is the state a user reaches just before
+ * signing. `floor-design.spec.ts` reads the same control the same way.
*/
test('FD-E the review gate is live and refuses a floor that is not CONSTRUCTIBLE',
async ({ pro: page }) => {
@@ -280,18 +295,22 @@ test.describe('@smoke the floor-family deliverable, end to end', () => {
await page.getByTestId('review-engineer').fill('Bauti');
await page.getByTestId('review-notes').fill('QA journey');
- await page.getByTestId('review-submit').click();
// Refused, with the reason on screen and the state named — not silently ignored, and not
- // recorded.
- const error = page.getByTestId('review-error');
- await expect(error).toBeVisible();
+ // recorded. Everything a user could supply has been supplied: the acknowledgements, the
+ // engineer and the notes. What remains is the state, and the state is not negotiable.
+ await expect(page.getByTestId('review-submit')).toBeDisabled();
+ const blockers = page.getByTestId('review-blockers');
+ await expect(blockers).toBeVisible();
// The refusal names the STATE that blocked it and the state it needs. Both are
// engineering identifiers and stay untranslated; the sentence around them does not — this
// message used to be a Spanish literal built inside a pure module, so an English-locale
// user was refused in Spanish. Found by this journey.
- await expect(error).toContainText('CONSTRUCTIBLE');
- await expect(error).toContainText('only be reviewed');
+ await expect(blockers).toContainText('CONSTRUCTIBLE');
+ await expect(blockers).toContainText('only be reviewed');
+ // And the reason it gives is the STATE, not the two things the user already did: a blocker
+ // list still asking for a name after one was typed would be the same dead end in words.
+ await expect(blockers).not.toContainText('engineer');
await expect(page.getByTestId('review-record')).toHaveCount(0);
// And the blocking conditions are visible, so the reason is actionable rather than a
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/e2e/footing-status-tokens.spec.ts b/web/e2e/footing-status-tokens.spec.ts
new file mode 100644
index 000000000..ab68371ff
--- /dev/null
+++ b/web/e2e/footing-status-tokens.spec.ts
@@ -0,0 +1,311 @@
+/**
+ * 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);
+ });
+ });
+}
+
+/**
+ * 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/e2e/h1a-audit.spec.ts b/web/e2e/h1a-audit.spec.ts
new file mode 100644
index 000000000..75e5af096
--- /dev/null
+++ b/web/e2e/h1a-audit.spec.ts
@@ -0,0 +1,262 @@
+/**
+ * H1-A — the audit harness. It MEASURES; it does not assert.
+ *
+ * The point of a separate phase is to stop guessing which of the viewer's differences from the
+ * rest of PRO are defects and which are just a different look. So this walks four screens in
+ * three languages at two widths and prints structural facts: horizontal overflow, heading
+ * sequence, controls with no accessible name, small text under AA, and the chrome each surface
+ * paints itself with — font, radius, surface, hairline.
+ *
+ * It is `@audit`-tagged and excluded from nothing, because it cannot fail on a finding. What it
+ * finds becomes assertions in H1-B..E, in the files those phases touch.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+import { writeFileSync, mkdirSync } from 'node:fs';
+
+const OUT = process.env.H1A_OUT ?? 'e2e/.artifacts/h1a';
+
+/** Everything the audit reads off one subtree, computed in the page. */
+const PROBE = `(rootSel) => {
+ const root = document.querySelector(rootSel);
+ if (!root) return { missing: rootSel };
+
+ const lum = (c) => {
+ const m = c.match(/\\d+(\\.\\d+)?/g); if (!m) return null;
+ const [r, g, b] = m.slice(0, 3).map(Number);
+ const f = [r, g, b].map((v) => v / 255)
+ .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
+ return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
+ };
+ /** The first ancestor with a non-transparent background — what the text actually sits on. */
+ const groundOf = (el) => {
+ for (let n = el; n; n = n.parentElement) {
+ const bg = getComputedStyle(n).backgroundColor;
+ const a = bg.match(/rgba?\\([^)]*?([\\d.]+)\\)/);
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && (!a || Number(a[1]) > 0.5)) return bg;
+ }
+ return getComputedStyle(document.body).backgroundColor;
+ };
+ const ratio = (fg, bg) => {
+ const [a, b] = [lum(fg), lum(bg)];
+ if (a === null || b === null) return null;
+ const [x, y] = a > b ? [a, b] : [b, a];
+ return (x + 0.05) / (y + 0.05);
+ };
+
+ const all = [root, ...root.querySelectorAll('*')];
+
+ // ── horizontal overflow, excluding elements whose job is to scroll ──
+ const overflow = [];
+ for (const el of all) {
+ const cs = getComputedStyle(el);
+ if (cs.overflowX === 'auto' || cs.overflowX === 'scroll') continue;
+ // \`.sr-only\` is clipped ON PURPOSE and an SVG child's scrollWidth means nothing. The first
+ // version of this reported both and made twelve findings out of zero.
+ if (el.closest('.sr-only') || el.classList.contains('sr-only')) continue;
+ if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml') continue;
+ if (el.scrollWidth > el.clientWidth + 1 && el.clientWidth > 0) {
+ overflow.push({
+ tag: el.tagName.toLowerCase(),
+ cls: (el.className || '').toString().split(' ')[0],
+ testid: el.getAttribute('data-testid'),
+ by: el.scrollWidth - el.clientWidth,
+ });
+ }
+ }
+
+ // ── heading sequence: a jump is a hierarchy defect ──
+ const headings = [...root.querySelectorAll('h1,h2,h3,h4,h5,h6')]
+ .map((h) => ({ level: Number(h.tagName[1]), text: (h.textContent || '').trim().slice(0, 40) }));
+ const jumps = [];
+ for (let i = 1; i < headings.length; i++) {
+ if (headings[i].level - headings[i - 1].level > 1) {
+ jumps.push(\`h\${headings[i - 1].level} → h\${headings[i].level} at "\${headings[i].text}"\`);
+ }
+ }
+
+ // ── controls a screen reader cannot name ──
+ /**
+ * The accessible name, near enough to the real algorithm to stop lying.
+ *
+ * The first version read only aria-label / title / textContent OF THE CONTROL, and reported
+ * twenty-five unnamed inputs. Every one of them was a checkbox, radio or range inside a
+ * \`\` carrying visible text — named, correctly, by the wrapper. Sources, in order:
+ * aria-labelledby, aria-label, a wrapping label, label[for], title, own text, and for an
+ * its placeholder or value.
+ */
+ const nameOf = (el) => {
+ const by = el.getAttribute('aria-labelledby');
+ if (by) {
+ const t = by.split(/\\s+/).map((id) => document.getElementById(id))
+ .filter(Boolean).map((n) => n.textContent.trim()).join(' ').trim();
+ if (t) return t;
+ }
+ const aria = (el.getAttribute('aria-label') || '').trim();
+ if (aria) return aria;
+ const wrapping = el.closest('label');
+ if (wrapping) {
+ const t = wrapping.textContent.trim();
+ if (t) return t;
+ }
+ if (el.id) {
+ const forEl = document.querySelector('label[for="' + el.id + '"]');
+ if (forEl && forEl.textContent.trim()) return forEl.textContent.trim();
+ }
+ const title = (el.getAttribute('title') || '').trim();
+ if (title) return title;
+ const own = (el.textContent || '').trim();
+ if (own) return own;
+ return (el.getAttribute('placeholder') || el.getAttribute('value') || '').trim();
+ };
+
+ const unnamed = [];
+ for (const el of root.querySelectorAll('button,a[href],select,input,[role="tab"],[role="button"]')) {
+ if (el.type === 'hidden') continue;
+ const name = nameOf(el);
+ if (!name) {
+ unnamed.push({
+ tag: el.tagName.toLowerCase(), testid: el.getAttribute('data-testid'),
+ cls: (el.className || '').toString().split(' ')[0],
+ });
+ }
+ }
+
+ // ── small text under AA ──
+ const lowContrast = [];
+ for (const el of all) {
+ if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml') continue; // SVG text, again
+ if (!el.childNodes.length) continue;
+ const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
+ if (!own) continue;
+ const cs = getComputedStyle(el);
+ const px = parseFloat(cs.fontSize);
+ const bold = Number(cs.fontWeight) >= 700;
+ const large = px >= 24 || (px >= 18.66 && bold);
+ const r = ratio(cs.color, groundOf(el));
+ if (r !== null && r < (large ? 3 : 4.5)) {
+ lowContrast.push({
+ cls: (el.className || '').toString().split(' ')[0],
+ testid: el.getAttribute('data-testid'),
+ px: Math.round(px * 10) / 10, ratio: Math.round(r * 100) / 100,
+ // The pair, so a finding is actionable without a second run.
+ fg: cs.color, ground: groundOf(el),
+ text: (el.textContent || '').trim().slice(0, 32),
+ });
+ }
+ }
+
+ // ── the chrome this surface paints itself with ──
+ const tally = (fn) => {
+ const m = {};
+ for (const el of all) { const v = fn(getComputedStyle(el)); if (v) m[v] = (m[v] || 0) + 1; }
+ return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 6);
+ };
+
+ return {
+ box: { scroll: root.scrollWidth, client: root.clientWidth },
+ nodes: all.length,
+ overflow: overflow.slice(0, 12),
+ headings: headings.map((h) => \`h\${h.level} \${h.text}\`),
+ headingJumps: jumps,
+ unnamed: unnamed.slice(0, 12),
+ lowContrast: lowContrast.slice(0, 12),
+ fonts: tally((cs) => cs.fontFamily.split(',')[0].replace(/["']/g, '')),
+ radii: tally((cs) => (cs.borderRadius !== '0px' ? cs.borderRadius : null)),
+ fontSizes: tally((cs) => cs.fontSize),
+ };
+}`;
+
+const probe = (page: Page, sel: string) =>
+ page.evaluate(new Function('return ' + PROBE)() as never, sel);
+
+/* ── the four routes ─────────────────────────────────────────────── */
+
+async function toDesignStage(page: Page) {
+ await loadModel(page, 'rc-design-qa-8');
+ await designAll(page);
+ await page.getByTestId('pr-stage-design').click();
+ await page.getByTestId('pr-cmd-design').click();
+}
+
+async function toDetailing(page: Page) {
+ await toDesignStage(page);
+ const d = page.getByTestId('detailing-disclosure');
+ if (await d.count() && await d.getAttribute('open') === null) {
+ await d.locator('> summary').click();
+ }
+ const generate = page.getByTestId('cmd-generate-detailing');
+ if (await generate.count() && await generate.isEnabled()) {
+ await generate.click();
+ await expect
+ .poll(() => page.evaluate(() =>
+ (window.__stabileo as unknown as { detailingAssemblies(): unknown[] })
+ .detailingAssemblies().length), { timeout: 60_000 })
+ .toBeGreaterThan(0);
+ }
+}
+
+async function toDocuments(page: Page) {
+ await toDetailing(page);
+ await openDocumentsStage(page);
+}
+
+async function toWorkspace(page: Page) {
+ await toDocuments(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);
+}
+
+const SCREENS = [
+ { id: 'design', sel: '[data-testid="pro-panel"], .pro-panel', go: toDesignStage },
+ { id: 'detailing', sel: '[data-testid="detailing-workflow"]', go: toDetailing },
+ // `documents-stage`, not `.documents`. The first version named a testid that does not exist
+ // and fell through to `.documents`, which is ONE card inside the stage — 8 nodes measured
+ // against the stage's real tree, so that screen was reported clean without being looked at.
+ { id: 'documents', sel: '[data-testid="documents-stage"]', go: toDocuments },
+ { id: 'workspace', sel: '[data-testid="rebar-workspace"]', go: toWorkspace },
+] as const;
+
+function save(name: string, data: unknown) {
+ mkdirSync(OUT, { recursive: true });
+ writeFileSync(`${OUT}/${name}.json`, JSON.stringify(data, null, 2));
+}
+
+for (const locale of ['en', 'es', 'pt'] as const) {
+ test.describe(`@audit ${locale} at 1280×720`, () => {
+ test.slow();
+ test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } });
+
+ for (const screen of SCREENS) {
+ test(`${screen.id}`, async ({ pro: page }) => {
+ await screen.go(page);
+ const r = await probe(page, screen.sel);
+ save(`${locale}-1280-${screen.id}`, r);
+ console.log(`H1A ${locale} 1280 ${screen.id} ${JSON.stringify(r)}`);
+ });
+ }
+ });
+}
+
+/** The narrow case, one language. 1024 is the width the PRO rail is tightest at in practice. */
+test.describe('@audit es at 1024×700', () => {
+ test.slow();
+ test.use({ appLocale: 'es', viewport: { width: 1024, height: 700 } });
+
+ for (const screen of SCREENS) {
+ test(`${screen.id} narrow`, async ({ pro: page }) => {
+ await screen.go(page);
+ const r = await probe(page, screen.sel);
+ save(`es-1024-${screen.id}`, r);
+ console.log(`H1A es 1024 ${screen.id} ${JSON.stringify(r)}`);
+ });
+ }
+});
diff --git a/web/e2e/h1b-panel-navigation.spec.ts b/web/e2e/h1b-panel-navigation.spec.ts
new file mode 100644
index 000000000..b4a7cabed
--- /dev/null
+++ b/web/e2e/h1b-panel-navigation.spec.ts
@@ -0,0 +1,268 @@
+/**
+ * H1-B — the right-hand panel: heading order, focus, Escape, and long content.
+ *
+ * H1-A deferred all four of these on purpose: it measured screens at rest and said so. These
+ * assert them, and three of the four turned out to be things that already WORK — which is worth
+ * pinning precisely because nothing was protecting them.
+ *
+ * The fourth is a defect in a file H1 does not own, so it is marked `test.fail()` rather than
+ * quietly asserted away. See the last describe.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+
+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();
+}
+
+async function generateDetailing(page: Page) {
+ const d = page.getByTestId('detailing-disclosure');
+ if (await d.getAttribute('open') === null) await d.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);
+}
+
+/** The heading levels inside a subtree, in document order. */
+const headings = (page: Page, sel: string) =>
+ page.evaluate((s) => {
+ const root = document.querySelector(s);
+ if (!root) return null;
+ return [...root.querySelectorAll('h1,h2,h3,h4,h5,h6')]
+ .map((h) => ({ level: Number(h.tagName[1]), text: (h.textContent ?? '').trim().slice(0, 30) }));
+ }, sel);
+
+function jumps(list: Array<{ level: number; text: string }>) {
+ const out: string[] = [];
+ for (let i = 1; i < list.length; i++) {
+ if (list[i].level - list[i - 1].level > 1) {
+ out.push(`h${list[i - 1].level} → h${list[i].level} at "${list[i].text}"`);
+ }
+ }
+ return out;
+}
+
+for (const locale of ['en', 'es', 'pt'] as const) {
+ test.describe(`@slow heading order in ${locale}`, () => {
+ test.slow();
+ test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } });
+
+ test('the design panel skips no level', async ({ pro: page }) => {
+ await design(page);
+ const h = await headings(page, '.pro-panel');
+ expect(h, 'the panel must be on screen').not.toBeNull();
+ /*
+ * `h3 → h5` at "Engineer review" was the jump H1-A found, in all three languages. Both
+ * headings live in `DocumentsSection`, so the fix was internal to one file: a reader
+ * navigating by heading could not tell whether the review was a sibling of the documents
+ * or a part of them.
+ */
+ expect(jumps(h!), 'no level is skipped').toEqual([]);
+ });
+
+ test('the workspace rail skips no level', async ({ pro: page }) => {
+ await design(page);
+ await generateDetailing(page);
+ await openDocumentsStage(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);
+
+ const h = await headings(page, '[data-testid="rebar-workspace"]');
+ // `h2 → h4` at "Layers": the overlay's title is the h2, so a rail panel is its child.
+ expect(jumps(h!), 'no level is skipped').toEqual([]);
+ expect(h![0].level, 'the overlay still opens at h2').toBe(2);
+ });
+ });
+}
+
+test.describe('@slow focus and Escape', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the overlay takes focus on open and gives it back on Escape',
+ async ({ pro: page }) => {
+ await design(page);
+ await generateDetailing(page);
+ await openDocumentsStage(page);
+
+ /*
+ * This already worked, and nothing was checking it. `RebarWorkspace` uses `captureFocus`
+ * from `lib/utils/dialog-focus.ts` and its comment explains why: the opener is a button
+ * this overlay COVERS, so without the restore, Escape returned the user to ``.
+ * Pinned here so a future refactor of the overlay cannot lose it silently.
+ */
+ const opener = page.getByTestId('doc-3d');
+ await opener.focus();
+ await opener.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(0);
+
+ expect(await page.evaluate(() =>
+ document.querySelector('[data-testid="rebar-workspace"]')!
+ .contains(document.activeElement)),
+ 'focus lands inside the overlay').toBe(true);
+
+ await page.keyboard.press('Escape');
+ await expect(page.getByTestId('rebar-workspace')).toHaveCount(0);
+ expect(await page.evaluate(() => document.activeElement?.getAttribute('data-testid')),
+ 'and returns to the control the user left').toBe('doc-3d');
+ });
+
+ test('opening a stage from the strip leaves focus on the strip, which is where it belongs',
+ async ({ pro: page }) => {
+ await design(page);
+ /*
+ * Measured rather than assumed to be a defect. Clicking a stage in the strip opens the
+ * matching `` and focus stays on the strip button — which is correct for a
+ * non-modal disclosure: the user pressed a button, the button is still there, and moving
+ * focus into freshly revealed content is the behaviour a modal owes and a disclosure does
+ * not.
+ *
+ * Recorded so that "improving" it later is a decision rather than a drift.
+ */
+ await page.getByTestId('pr-stage-design').click();
+ expect(await page.evaluate(() => document.activeElement?.getAttribute('data-testid')))
+ .toBe('pr-stage-design');
+
+ const d = page.getByTestId('detailing-disclosure');
+ await d.locator('> summary').click();
+ const onSummary = await page.evaluate(() =>
+ document.activeElement?.tagName.toLowerCase());
+ expect(onSummary, 'clicking the summary focuses the summary').toBe('summary');
+ });
+
+ test('Escape does NOT close a stage section, and the overlay is the only modal',
+ async ({ pro: page }) => {
+ await design(page);
+ const d = page.getByTestId('detailing-disclosure');
+ if (await d.getAttribute('open') === null) await d.locator('> summary').click();
+ expect(await d.getAttribute('open'), 'the section is open').not.toBeNull();
+
+ await page.keyboard.press('Escape');
+ /*
+ * Asserted as it is, not as it might be nicer. A `` is not a dialog and Escape
+ * has no native meaning on one; the overlay closes on Escape because it IS modal. Making
+ * a disclosure close on Escape would be a new convention, and inventing one here would
+ * make the two behaviours look like a pair when only one of them is standard.
+ *
+ * Reported to Bauti as a question rather than settled in a test.
+ */
+ expect(await d.getAttribute('open'), 'a disclosure ignores Escape').not.toBeNull();
+ });
+});
+
+test.describe('@slow long content', () => {
+ test.slow();
+
+ for (const [w, h] of [[1280, 720], [1024, 700]] as const) {
+ test(`an 80-character label wraps instead of widening the panel at ${w}×${h}`,
+ async ({ pro: page }) => {
+ await page.setViewportSize({ width: w, height: h });
+ await design(page);
+ /*
+ * H1-A measured the fixture's own text lengths and said it had not measured synthetic
+ * ones. This is that: a member label a real project can produce, injected into six
+ * labels at once, which is worse than any single one.
+ */
+ const r = await page.evaluate(() => {
+ const panel = document.querySelector('.pro-panel') as HTMLElement;
+ const before = panel.scrollWidth - panel.clientWidth;
+ const victims = [...panel.querySelectorAll('.label, .fstate, dt, dd')]
+ .slice(0, 6) as HTMLElement[];
+ const saved = victims.map((v) => v.textContent);
+ const long = 'Viga continua de hormigon armado sobre apoyos elasticos - nivel +12.40';
+ victims.forEach((v) => { v.textContent = long; });
+ const after = panel.scrollWidth - panel.clientWidth;
+ const grew = victims.map((v) => Math.round(v.getBoundingClientRect().height));
+ victims.forEach((v, i) => { v.textContent = saved[i]; });
+ return { before, after, n: victims.length, grew };
+ });
+
+ expect(r.n, 'labels were found to lengthen').toBeGreaterThan(0);
+ expect(r.after, 'the panel does not widen').toBeLessThanOrEqual(r.before + 1);
+ // And they DID grow, so the wrap is real rather than the text being clipped away.
+ expect(Math.max(...r.grew), 'the labels wrapped').toBeGreaterThan(20);
+ });
+ }
+});
+
+test.describe('@slow the workflow strip', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the strip itself never scrolls sideways', async ({ pro: page }) => {
+ await design(page);
+ for (const w of [1280, 1024, 900, 820]) {
+ await page.setViewportSize({ width: w, height: 720 });
+ const over = await page.getByTestId('workflow-stages').locator('ol')
+ .evaluate((el) => el.scrollWidth - el.clientWidth);
+ expect(over, `no sideways scroll at ${w}`).toBeLessThanOrEqual(1);
+ }
+ });
+
+ test('no wrapped row ends in a chevron pointing at nothing', async ({ pro: page }) => {
+ /*
+ * `test.fail()` INSIDE the body, not at describe scope.
+ *
+ * At describe scope it marks every test in the block, and the first version did exactly
+ * that: Playwright reported "Expected to fail, but passed" against the sideways-scroll test
+ * above, which passes and should. One misplaced line turned a green assertion into a red
+ * one and would have hidden this one entirely.
+ */
+ test.fail();
+ /**
+ * PR20 reported "the workflow strip wraps at 1280×720 with a dangling chevron". H1-A found
+ * no OVERFLOW and said that was a different claim needing a different measurement. This is
+ * it, and the claim is CONFIRMED:
+ *
+ * `ol` is `flex-wrap: wrap`, and at 1280, 1024, 900 and 820 it makes the same two rows —
+ * five stages, then `stage-documents` alone. The chevron comes from
+ * `.stage:not(:last-child)::after`, so `stage-detailing` draws one and it is the last item
+ * on row 1: a `›` pointing at the end of the line.
+ *
+ * It is width-independent because the strip lives in the fixed-width PRO sidebar, so it
+ * is not a narrow-viewport problem at all.
+ *
+ * `test.fail()` because `WorkflowStages.svelte` is shared PRO chrome — M1's metallic flow
+ * renders inside the same strip — and H1 does not edit it unilaterally. Marked rather than
+ * skipped, so the day someone fixes it Playwright reports this as an unexpected PASS and the
+ * marker comes off. A test that asserted the defect would have to be inverted instead.
+ */
+ const rows = await page.getByTestId('workflow-stages').locator('ol').evaluate((ol) => {
+ const items = [...ol.children] as HTMLElement[];
+ const byTop = new Map();
+ for (const li of items) {
+ const t = Math.round(li.getBoundingClientRect().top);
+ if (!byTop.has(t)) byTop.set(t, []);
+ byTop.get(t)!.push(li);
+ }
+ // A row ends in a chevron when its last item is not the last item overall.
+ return [...byTop.values()].map((row) => ({
+ ids: row.map((li) => li.getAttribute('data-testid')),
+ endsWithChevron: row[row.length - 1] !== items[items.length - 1],
+ }));
+ });
+ expect(rows.filter((r) => r.endsWithChevron), 'rows ending in a dangling chevron')
+ .toEqual([]);
+ });
+});
diff --git a/web/e2e/h1c-documents-flow.spec.ts b/web/e2e/h1c-documents-flow.spec.ts
new file mode 100644
index 000000000..98918ddbb
--- /dev/null
+++ b/web/e2e/h1c-documents-flow.spec.ts
@@ -0,0 +1,398 @@
+/**
+ * H1-C — the documents stage, on a fixture that actually reaches its states.
+ *
+ * H1-A measured this stage at 24 nodes and reported it clean. It had measured `.documents`, which
+ * is ONE CARD inside the stage, and it had never built a document — so "clean" meant "nothing was
+ * looked at". This is the real pass.
+ *
+ * ── The chain, and the one thing that was wrong with it ────────────
+ *
+ * state → export → review → acceptance → issue. The document is built LAZILY, by the first
+ * export: before that the stage says "No document built yet" while the three export buttons are
+ * enabled, and clicking one builds the model and updates the panel. That is deliberate and it
+ * works — `doc-xlsx` downloads `detailing-rev1.xlsx` and `doc-readiness` appears.
+ *
+ * What was wrong: `Record review` had no `disabled` and no explanation. Clicking it with an
+ * unaccepted provisional calculation called `detailingStore.review`, which refuses — but only
+ * after `retireDocument()` has already run. So a click that accomplished nothing SUPERSEDED the
+ * document the user had just built. The three refusals are now stated before the click, in the
+ * store's own words and from the same locale keys.
+ *
+ * The store's ordering is now fixed too, in a separate authorised change: `retireDocument()` runs
+ * AFTER `applyReview` decides. Both halves are asserted — the gate stops a user reaching the
+ * refusal, and the last describe proves the store underneath is no longer destructive if anything
+ * else does.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+
+/** Detailing generated, documents open, and nothing built yet. */
+async function toDocuments(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 expect(page.getByTestId('documents-stage')).toBeVisible();
+}
+
+/** …and one export run, so a document exists. */
+async function withDocument(page: Page) {
+ await toDocuments(page);
+ const download = page.waitForEvent('download', { timeout: 30_000 });
+ await page.getByTestId('doc-xlsx').click();
+ const file = await download;
+ await expect(page.getByTestId('doc-readiness')).toBeVisible();
+ return file;
+}
+
+test.describe('@slow the export builds the document, and says so', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('before any export the stage states the absence rather than showing a figure',
+ async ({ pro: page }) => {
+ await toDocuments(page);
+ await expect(page.getByTestId('doc-none')).toBeVisible();
+ await expect(page.getByTestId('doc-readiness')).toHaveCount(0);
+ // The exports are enabled on purpose: the first one is what builds the model.
+ for (const id of ['doc-report', 'doc-dxf', 'doc-xlsx', 'doc-3d']) {
+ await expect(page.getByTestId(id), `${id} is offered`).toBeEnabled();
+ }
+ });
+
+ test('an export produces a real file and the panel picks up the revision',
+ async ({ pro: page }) => {
+ const file = await withDocument(page);
+ /*
+ * A real download, named after the revision it belongs to. Asserted because the first probe
+ * of this looked for a download from `doc-report` — which uses `window.open` + `print()`,
+ * not a download — and concluded the export was a silent no-op. It is not; the signal was
+ * the wrong one.
+ */
+ expect(await file.suggestedFilename()).toMatch(/^detailing-rev\d+\.xlsx$/);
+ await expect(page.getByTestId('doc-none')).toHaveCount(0);
+ await expect(page.getByTestId('doc-revision')).toContainText(/1/);
+ await expect(page.getByTestId('doc-error')).toHaveCount(0);
+ });
+});
+
+test.describe('@slow the stage says what the document IS, not only how ready it is', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the contents are real figures from the document model', async ({ pro: page }) => {
+ await withDocument(page);
+ /*
+ * Before this the stage showed readiness, revision and maturity: three states and no content.
+ * A reader could not tell whether "Revision 1" covered one assembly or forty, which codes it
+ * was verified against, or whether it carried assumptions — all of which `DocumentModel`
+ * already holds.
+ */
+ await expect(page.getByTestId('doc-contents')).toBeVisible();
+ for (const id of ['doc-count-assemblies', 'doc-count-certificates', 'doc-count-clauses']) {
+ const text = (await page.getByTestId(id).innerText()).trim();
+ expect(text, `${id} is a figure`).toMatch(/^\d+$/);
+ expect(Number(text), `${id} is a figure that was taken, not a placeholder`)
+ .toBeGreaterThan(0);
+ }
+ // The editions the verification used, not the ones currently selected.
+ await expect(page.getByTestId('doc-regulations')).toBeVisible();
+ expect((await page.getByTestId('doc-regulations').innerText()).trim().length)
+ .toBeGreaterThan(3);
+ });
+
+ test('and none of it is shown before a document exists', async ({ pro: page }) => {
+ await toDocuments(page);
+ // The other half of "no fabricated zeros": with nothing built there are no counts at all,
+ // rather than a row of noughts.
+ await expect(page.getByTestId('doc-contents')).toHaveCount(0);
+ await expect(page.getByTestId('doc-regulations')).toHaveCount(0);
+ await expect(page.getByTestId('doc-none')).toBeVisible();
+ });
+
+ test('a superseded document is kept and named, not deleted', async ({ pro: page }) => {
+ await withDocument(page);
+ // A recorded review retires the current document, which is where a superseded one comes from.
+ await page.getByTestId('review-engineer').fill('Bautista Chesta');
+ const acks = page.locator('[data-testid^="ack-"]');
+ for (let i = 0; i < await acks.count(); i++) await acks.nth(i).check();
+ await page.getByTestId('review-submit').click();
+ await expect(page.getByTestId('review-record')).toBeVisible();
+
+ const list = page.getByTestId('superseded-docs');
+ await expect(list, 'the retired revision is kept').toBeVisible();
+ if (await list.getAttribute('open') === null) await list.locator('> summary').click();
+ const items = list.locator('[data-testid^="superseded-"]');
+ expect(await items.count(), 'and listed by revision').toBeGreaterThan(0);
+ /*
+ * Named, not merely counted. "A project that cannot show what it previously issued cannot
+ * answer the only question that matters after something goes wrong" —
+ * `footing-document-slice.test.ts` says it about the renderer; the same is true of the panel.
+ */
+ expect((await items.first().innerText()).trim().length).toBeGreaterThan(8);
+ });
+});
+
+test.describe('@slow review is gated by the reasons the store would give', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('it is disabled with the refusals stated, and enables as each is met',
+ async ({ pro: page }) => {
+ await withDocument(page);
+ const submit = page.getByTestId('review-submit');
+ const blockers = page.getByTestId('review-blockers');
+
+ // Two refusals up front: no named engineer, and an unaccepted provisional calculation.
+ await expect(submit).toBeDisabled();
+ await expect(blockers).toBeVisible();
+ const both = await blockers.innerText();
+ expect(both.length, 'the reasons are sentences, not a grey button')
+ .toBeGreaterThan(40);
+
+ await page.getByTestId('review-engineer').fill('Bautista Chesta');
+ await expect(submit, 'still blocked by the provisional').toBeDisabled();
+ expect((await blockers.innerText()).length, 'one reason fewer')
+ .toBeLessThan(both.length);
+
+ const acks = page.locator('[data-testid^="ack-"]');
+ const n = await acks.count();
+ expect(n, 'this fixture has a provisional calculation to accept').toBeGreaterThan(0);
+ for (let i = 0; i < n; i++) await acks.nth(i).check();
+
+ await expect(submit, 'every refusal met').toBeEnabled();
+ await expect(blockers).toHaveCount(0);
+ // And the document survived being blocked, which is the whole point.
+ await expect(page.getByTestId('doc-readiness')).toBeVisible();
+ });
+
+ test('and once recorded, the review succeeds and issue opens', async ({ pro: page }) => {
+ await withDocument(page);
+ await page.getByTestId('review-engineer').fill('Bautista Chesta');
+ const acks = page.locator('[data-testid^="ack-"]');
+ for (let i = 0; i < await acks.count(); i++) await acks.nth(i).check();
+ await page.getByTestId('review-submit').click();
+
+ await expect(page.getByTestId('review-record')).toBeVisible();
+ await expect(page.getByTestId('review-error'), 'no refusal').toHaveCount(0);
+ await expect(page.getByTestId('issue-submit'), 'issue is now reachable').toBeEnabled();
+ /*
+ * `doc-readiness` goes on a SUCCESSFUL review too, and that is correct: the store's comment
+ * says a review changes the readiness a document may claim, so the previous one is no longer
+ * current. Asserted so the two cases are not confused — this one is by design, the refusal
+ * path was not.
+ */
+ await expect(page.getByTestId('doc-none')).toBeVisible();
+ });
+
+ test('the issue blockers shrink as the conditions are met, and name what is left',
+ async ({ pro: page }) => {
+ await withDocument(page);
+ const blockers = page.getByTestId('issue-blockers');
+ const before = await blockers.innerText();
+ expect(before, 'both conditions named').toMatch(/review/i);
+
+ await page.getByTestId('review-engineer').fill('Bautista Chesta');
+ const acks = page.locator('[data-testid^="ack-"]');
+ for (let i = 0; i < await acks.count(); i++) await acks.nth(i).check();
+ expect((await blockers.innerText()).length, 'the accepted condition is gone')
+ .toBeLessThan(before.length);
+ await expect(page.getByTestId('issue-submit')).toBeDisabled();
+ });
+});
+
+/**
+ * The regression the store fix earns.
+ *
+ * `retireDocument()` now runs AFTER `applyReview` decides, so a refused review costs nothing.
+ * Exercised through `__stabileo.reviewAssembly`, which is the hook `e2e-hooks.ts` already exposes
+ * for exactly this — and it has to be, because the UI gate added in this same block makes the
+ * refusal UNREACHABLE by clicking. Both halves matter: the gate stops the user reaching it, and
+ * this proves the store underneath is no longer destructive if anything else does.
+ *
+ * No unit test covers this. `detailingStore.assemblies` is populated by the MEMBER detailing run,
+ * and the footing-only fixture in `footing-document-slice.test.ts` leaves it empty — a `review()`
+ * there returns false from `if (!selected)` with no `lastError`, which is a refusal for entirely
+ * the wrong reason. That file now says so in place rather than passing on it.
+ */
+test.describe('@slow a refused review does not cost the document', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the document and the superseded list are untouched by a refusal',
+ async ({ pro: page }) => {
+ await withDocument(page);
+ await expect(page.getByTestId('doc-readiness')).toBeVisible();
+ const revision = await page.getByTestId('doc-revision').innerText();
+ const supersededBefore = await page.getByTestId('superseded-docs').count();
+
+ // An empty engineer is the refusal that needs no other state — `assembly.ts:488`.
+ const refused = await page.evaluate(() => {
+ /*
+ * `__stabileoActions`, not `__stabileo`. The split is deliberate and documented in
+ * `e2e-hooks.ts`: "`window.__stabileo` is READ-ONLY: queries only, frozen, no state
+ * setters", and mutations live on the actions object. The first version of this reached
+ * for the query object and got `not a function`.
+ */
+ const w = window as unknown as {
+ __stabileoActions: { reviewAssembly(r: unknown): boolean };
+ };
+ return w.__stabileoActions.reviewAssembly({
+ engineer: ' ',
+ at: new Date().toISOString(),
+ state: 'REVIEWED',
+ provisionalAcknowledged: true,
+ acknowledgedProvisional: [],
+ });
+ });
+ expect(refused, 'the store refuses').toBe(false);
+
+ // The whole point: nothing was retired on the way to that refusal.
+ await expect(page.getByTestId('doc-readiness'), 'the document survives').toBeVisible();
+ await expect(page.getByTestId('doc-revision'), 'at the same revision')
+ .toHaveText(revision);
+ expect(await page.getByTestId('superseded-docs').count(),
+ 'and nothing joined the superseded list').toBe(supersededBefore);
+ // And the refusal is reported, in this locale.
+ await expect(page.getByTestId('review-error')).toBeVisible();
+ });
+});
+
+test.describe('@slow focus inside the stage', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('an export leaves focus on the button that ran it', async ({ pro: page }) => {
+ await toDocuments(page);
+ const download = page.waitForEvent('download', { timeout: 30_000 });
+ await page.getByTestId('doc-xlsx').click();
+ await download;
+ /*
+ * Measured, and recorded as correct rather than "improved". The panel gains a contents block
+ * where there was a "not built yet" line, which is a re-render around the control the user
+ * pressed — so the question worth asking is whether focus SURVIVES it, not whether it moves
+ * somewhere nicer.
+ */
+ expect(await page.evaluate(() => document.activeElement?.getAttribute('data-testid')),
+ 'focus survives the panel re-rendering around it').toBe('doc-xlsx');
+ });
+
+ test('a recorded review leaves focus on its own button', async ({ pro: page }) => {
+ await withDocument(page);
+ await page.getByTestId('review-engineer').fill('Bautista Chesta');
+ const acks = page.locator('[data-testid^="ack-"]');
+ for (let i = 0; i < await acks.count(); i++) await acks.nth(i).check();
+ await page.getByTestId('review-submit').click();
+ await expect(page.getByTestId('review-record')).toBeVisible();
+ /*
+ * Measured: focus stays on `review-submit`.
+ *
+ * The worry worth checking was that a review changes the state underneath the control that
+ * was pressed, and a control that becomes DISABLED loses focus to `` — a dead end for a
+ * keyboard user, whose next Tab restarts from the top of the document. It does not happen
+ * here: the button stays enabled and keeps focus. Asserted so that a future change to the
+ * gating cannot introduce the dead end unnoticed.
+ */
+ const after = await page.evaluate(() =>
+ document.activeElement === document.body
+ ? 'body'
+ : document.activeElement?.getAttribute('data-testid') ?? 'other');
+ expect(after, 'focus is not dropped to the document body').not.toBe('body');
+ expect(after).toBe('review-submit');
+ test.info().annotations.push(
+ { type: 'coverage', description: `focus after a recorded review: ${after}` });
+ });
+});
+
+/**
+ * Legibility, not fit.
+ *
+ * The stage fits at both widths in all three languages — measured, and it does. So the interesting
+ * assertion is the other one: that every sentence on it clears its contrast bar against the
+ * ground it actually sits on. "The container fits" and "the information is legible" are different
+ * claims and only the second one matters to a reader.
+ */
+const AUDIT = `(sel) => {
+ const root = document.querySelector(sel);
+ if (!root) return null;
+ const lum = (c) => {
+ const m = c.match(/\\d+(\\.\\d+)?/g); if (!m) return null;
+ const f = m.slice(0, 3).map(Number).map((v) => v / 255)
+ .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
+ return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
+ };
+ const ground = (el) => {
+ for (let n = el; n; n = n.parentElement) {
+ const bg = getComputedStyle(n).backgroundColor;
+ const a = bg.match(/rgba?\\([^)]*?([\\d.]+)\\)/);
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && (!a || Number(a[1]) > 0.5)) return bg;
+ }
+ return getComputedStyle(document.body).backgroundColor;
+ };
+ const bad = [];
+ let counted = 0;
+ for (const el of [root, ...root.querySelectorAll('*')]) {
+ if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml') continue;
+ if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 1)) continue;
+ const cs = getComputedStyle(el);
+ const px = parseFloat(cs.fontSize);
+ const need = (px >= 24 || (px >= 18.66 && Number(cs.fontWeight) >= 700)) ? 3 : 4.5;
+ const a = lum(cs.color), b = lum(ground(el));
+ if (a === null || b === null) continue;
+ counted += 1;
+ const [x, y] = a > b ? [a, b] : [b, a];
+ const ratio = (x + 0.05) / (y + 0.05);
+ if (ratio < need) {
+ bad.push(ratio.toFixed(2) + '/' + need + ' .'
+ + (el.className || '').toString().split(' ')[0] + ' ' + px + 'px "'
+ + (el.textContent || '').trim().slice(0, 24) + '"');
+ }
+ }
+ return { counted, overflow: root.scrollWidth - root.clientWidth, bad };
+}`;
+
+for (const locale of ['en', 'es', 'pt'] as const) {
+ for (const [w, h] of [[1280, 720], [1024, 700]] as const) {
+ test.describe(`@slow the documents stage in ${locale} at ${w}×${h}`, () => {
+ test.slow();
+ test.use({ appLocale: locale, viewport: { width: w, height: h } });
+
+ test('every sentence on it is legible, and it fits', async ({ pro: page }) => {
+ await withDocument(page);
+ const r = await page.evaluate(
+ new Function('return ' + AUDIT)() as never, '[data-testid="documents-stage"]',
+ ) as { counted: number; overflow: number; bad: string[] } | null;
+
+ expect(r, 'the stage must be on screen').not.toBeNull();
+ expect(r!.counted, 'text was actually measured').toBeGreaterThan(5);
+ expect(r!.bad, 'copy under its contrast bar').toEqual([]);
+ expect(r!.overflow, 'and the stage fits').toBeLessThanOrEqual(1);
+ test.info().annotations.push(
+ { type: 'coverage', description: `${r!.counted} text nodes` });
+ });
+
+ test('the review refusals are translated, not English fallbacks',
+ async ({ pro: page }) => {
+ await withDocument(page);
+ const blockers = page.getByTestId('review-blockers');
+ await expect(blockers).toBeVisible();
+ const text = await blockers.innerText();
+ expect(text.length, 'a real sentence').toBeGreaterThan(40);
+ if (locale !== 'en') {
+ // A cheap tripwire for a key that fell through to English rather than being
+ // translated: the English refusal opens with this exact phrase.
+ expect(text).not.toContain('The reviewing engineer must be named');
+ }
+ });
+ });
+ }
+}
diff --git a/web/e2e/h1d-viewer-audit.spec.ts b/web/e2e/h1d-viewer-audit.spec.ts
new file mode 100644
index 000000000..c407a35b6
--- /dev/null
+++ b/web/e2e/h1d-viewer-audit.spec.ts
@@ -0,0 +1,252 @@
+/**
+ * H1-D — the 3-D workspace: rail, isolation, filters, focus, Escape, empty state, return.
+ *
+ * H1-A measured the overlay at rest and said it had not measured the interactive states. This is
+ * those, and most of what it found was working. One thing was not, and it was a keyboard dead
+ * end.
+ *
+ * ── What the fixture can and cannot exercise ───────────────────────
+ *
+ * `rc-design-qa-8` builds a scene with 200 column bars, 226 beam bars, 8 solids and — measured —
+ * **zero conflicts and nothing unreinforced**. So `rebar-layer-conflicts` and
+ * `rebar-hide-unreinforced` change no census here. They are not dead controls; they have nothing
+ * to act on, and the difference matters: a test that called them broken would be wrong, and one
+ * that silently passed would prove nothing. Both are annotated as unexercised.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+
+async function openWorkspace(page: Page, withDetailing = true) {
+ await loadModel(page, 'rc-design-qa-8');
+ await designAll(page);
+ if (withDetailing) {
+ 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);
+ 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();
+ if (withDetailing) {
+ await expect
+ .poll(() => page.evaluate(() =>
+ (window.__stabileo as unknown as { rebarSceneBuilds(): number }).rebarSceneBuilds()),
+ { timeout: 120_000 })
+ .toBeGreaterThan(before);
+ }
+}
+
+const activeTestId = (page: Page) =>
+ page.evaluate(() => (document.activeElement === document.body
+ ? 'body'
+ : document.activeElement?.getAttribute('data-testid') ?? 'other'));
+
+test.describe('@slow isolation keeps the keyboard alive', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('isolating and clearing do not drop focus to the document body',
+ async ({ pro: page }) => {
+ await openWorkspace(page);
+ const rows = page.locator('[data-testid^="rebar-element-"]');
+ expect(await rows.count(), 'the rail lists members').toBeGreaterThan(0);
+ await rows.first().click();
+
+ /**
+ * The defect this pins.
+ *
+ * `Isolate` and `Clear isolation` were an `{#if}/{:else}` pair, so pressing one DESTROYED
+ * the pressed button and created the other. The focused element left the DOM and focus fell
+ * to `` — measured, twice: once on isolating and again on clearing. For a keyboard
+ * user that is a dead end whose next Tab restarts at the top of the document.
+ *
+ * One node now swaps its label, action and testid instead.
+ */
+ const isolate = page.getByTestId('rebar-isolate');
+ await expect(isolate).toBeVisible();
+ await isolate.focus();
+ await isolate.click();
+
+ await expect(page.getByTestId('rebar-clear-isolation'), 'the button became the other one')
+ .toBeVisible();
+ expect(await activeTestId(page), 'focus stayed on the control')
+ .toBe('rebar-clear-isolation');
+
+ await page.getByTestId('rebar-clear-isolation').click();
+ await expect(page.getByTestId('rebar-isolate')).toBeVisible();
+ expect(await activeTestId(page), 'and again on the way back').toBe('rebar-isolate');
+ });
+
+ test('the isolation reaches the scene', async ({ pro: page }) => {
+ await openWorkspace(page);
+ const rows = page.locator('[data-testid^="rebar-element-"]');
+ await rows.first().click();
+ const census = () => page.evaluate(() =>
+ JSON.stringify((window.__stabileo as unknown as { rebarSceneCensus(): unknown })
+ .rebarSceneCensus()));
+ const before = await census();
+ await page.getByTestId('rebar-isolate').click();
+ await expect(page.getByTestId('rebar-clear-isolation')).toBeVisible();
+ /*
+ * The MEMBER LIST is not what isolation filters, and that is correct: the list is how you
+ * choose what to isolate, so emptying it would take the way back out. What must change is the
+ * scene. Asserted on the census rather than on the list, which is what the first probe of
+ * this got wrong — it counted rows, saw 9 before and 9 after, and read that as isolation
+ * doing nothing.
+ */
+ expect(await census(), 'the drawn scene changed').not.toBe(before);
+ expect(await rows.count(), 'and the list is still a way back out').toBeGreaterThan(0);
+ });
+});
+
+test.describe('@slow filters and layers', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the bars and concrete layers change what is drawn', async ({ pro: page }) => {
+ await openWorkspace(page);
+ const census = () => page.evaluate(() =>
+ JSON.stringify((window.__stabileo as unknown as { rebarSceneCensus(): unknown })
+ .rebarSceneCensus()));
+ for (const id of ['rebar-layer-bars', 'rebar-layer-concrete']) {
+ const before = await census();
+ await page.getByTestId(id).click();
+ await expect.poll(census, { timeout: 10_000 }).not.toBe(before);
+ await page.getByTestId(id).click(); // and back, so each is independent
+ await expect.poll(census, { timeout: 10_000 }).toBe(before);
+ }
+ });
+
+ test('the conflict and unreinforced filters have nothing to act on here, and that is stated',
+ async ({ pro: page }) => {
+ await openWorkspace(page);
+ const c = await page.evaluate(() =>
+ (window.__stabileo as unknown as {
+ rebarSceneCensus(): { markers: number };
+ }).rebarSceneCensus());
+ /*
+ * The premise, asserted. If a future fixture DOES produce conflicts, this fails and the
+ * two controls below have to be exercised properly instead of annotated away.
+ */
+ expect(c.markers, 'this fixture draws no conflict markers').toBe(0);
+ for (const id of ['rebar-layer-conflicts', 'rebar-hide-unreinforced']) {
+ await expect(page.getByTestId(id), `${id} is still offered`).toBeAttached();
+ }
+ test.info().annotations.push({
+ type: 'coverage',
+ description: 'conflicts and unreinforced: controls present, nothing to filter on '
+ + 'rc-design-qa-8 — the conflicts layer IS exercised on rc-qa-diagnostic in '
+ + 'h1e-conflict-states.spec.ts; unreinforced still has no fixture',
+ });
+ });
+
+ test('opacity is a material property, so it moves no mesh count', async ({ pro: page }) => {
+ await openWorkspace(page);
+ const opacity = page.getByTestId('rebar-opacity');
+ await expect(opacity).toBeAttached();
+ const census = () => page.evaluate(() =>
+ JSON.stringify((window.__stabileo as unknown as { rebarSceneCensus(): unknown })
+ .rebarSceneCensus()));
+ const before = await census();
+ await opacity.fill('0.4');
+ /*
+ * Recorded because the first probe treated an unchanged census as a broken slider. Opacity
+ * changes a material, not the number of things drawn — the census is the wrong instrument,
+ * and saying so is worth more than a test that measures nothing.
+ */
+ expect(await census(), 'the census is not the instrument for this').toBe(before);
+ expect(await opacity.inputValue()).toBe('0.4');
+ });
+});
+
+test.describe('@slow the rail, and getting back out', () => {
+ test.slow();
+
+ test('at 1280×720 the rail is open and its toggle is hidden', async ({ pro: page }) => {
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await openWorkspace(page);
+ await expect(page.getByTestId('rebar-rail')).toBeVisible();
+ /*
+ * `rebar-rail-toggle` is `display: none` at this width — a 0×0 box. So the rail cannot be
+ * collapsed on a desktop, which is a product decision rather than a defect, and it is
+ * recorded here because it cost a five-minute timeout to discover: Playwright's `click()`
+ * waits for a hidden element forever.
+ */
+ const box = await page.getByTestId('rebar-rail-toggle')
+ .evaluate((el) => { const b = el.getBoundingClientRect(); return { w: b.width, h: b.height }; });
+ expect(box.w * box.h, 'the toggle is not rendered at this width').toBe(0);
+ });
+
+ test('Escape closes the overlay and lands back on the control that opened it',
+ async ({ pro: page }) => {
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await openWorkspace(page);
+ await page.keyboard.press('Escape');
+ await expect(page.getByTestId('rebar-workspace')).toHaveCount(0);
+ expect(await activeTestId(page)).toBe('doc-3d');
+ // And the workflow is where it was, not reset.
+ await expect(page.getByTestId('documents-stage')).toBeVisible();
+ });
+
+ test('the close button says where it goes, and it is the only button that goes there',
+ async ({ pro: page }) => {
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await openWorkspace(page);
+ const close = page.getByTestId('rebar-workspace-close');
+ await expect(close).toBeVisible();
+ /*
+ * It read "Back to the model", and closing returns to the design stage it was opened from —
+ * Documents — not to the model editor. Relabelled, in the three offered locales.
+ */
+ expect((await close.innerText()).toLowerCase()).toMatch(/workflow/);
+
+ /*
+ * A correction to an earlier reading of mine.
+ *
+ * `rebar-back` DOES exist in the markup, behind `{#if rebarWorkspace.canGoBack}` — which is
+ * `history.length > 0`, the SELECTION history. It is "Previous", for stepping back through
+ * members you have looked at, and it is absent here because a freshly opened overlay has no
+ * history yet. An earlier pass measured its absence on this exact route and reported it as
+ * "there is no Back button", which was the right measurement and the wrong conclusion.
+ *
+ * So: absent on a fresh workspace, and that is correct — not missing.
+ */
+ await expect(page.getByTestId('rebar-back'),
+ 'no selection history yet, so Previous is correctly absent').toHaveCount(0);
+
+ await close.click();
+ await expect(page.getByTestId('rebar-workspace')).toHaveCount(0);
+ expect(await activeTestId(page), 'and it returns focus like Escape does').toBe('doc-3d');
+ await expect(page.getByTestId('documents-stage')).toBeVisible();
+ });
+});
+
+test.describe('@slow the empty state', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('opening the viewer with no detailing says which families are missing',
+ async ({ pro: page }) => {
+ await openWorkspace(page, false);
+ /*
+ * `doc-3d` is enabled without any detailing and the overlay opens — which is consistent
+ * with the documents stage, where the exports are enabled and build on demand.
+ *
+ * What comes up is `rebar-empty-families`, not `rebar-workspace-empty`: the rail names the
+ * families that have nothing rather than the whole workspace claiming to be empty. Asserted
+ * as it is, because the two messages mean different things and only one of them is true.
+ */
+ await expect(page.getByTestId('rebar-empty-families')).toBeVisible();
+ const text = await page.getByTestId('rebar-empty-families').innerText();
+ expect(text.trim().length, 'and it is a sentence, not a blank').toBeGreaterThan(5);
+ });
+});
diff --git a/web/e2e/h1e-absence-states.spec.ts b/web/e2e/h1e-absence-states.spec.ts
new file mode 100644
index 000000000..5c941ba67
--- /dev/null
+++ b/web/e2e/h1e-absence-states.spec.ts
@@ -0,0 +1,189 @@
+/**
+ * The three different ways something can be missing, and which of them the app can reach.
+ *
+ * ── They are not one state ─────────────────────────────────────────
+ *
+ * ABSENCE OF ELEMENTS nothing was detailed, so there is nothing to document
+ * → `documents-empty` in the stage, `rebar-empty-families` in the rail
+ * NOTHING BUILT YET detailing exists, no document has been produced from it
+ * → `doc-none`
+ * A REFUSAL PER ELEMENT the design ran and could not find a passing arrangement
+ * → `REFUSED` in the rail
+ * A PASS ERROR `buildDocument` returned null
+ * → `doc-error`
+ *
+ * Conflating any two of them is how a panel comes to tell an engineer their building has no
+ * slabs. The first three are exercised here. The fourth is not reachable, and §the last describe
+ * says why rather than leaving a hole.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+
+type Hooks = {
+ __stabileo: { detailingAssemblies(): unknown[]; rebarSceneBuilds(): number };
+ __stabileoActions: { seedDetailing(a: unknown): void };
+};
+
+async function generate(page: Page, model = 'rc-design-qa-8') {
+ await loadModel(page, model);
+ 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 as unknown as Hooks).__stabileo.detailingAssemblies().length), { timeout: 120_000 })
+ .toBeGreaterThan(0);
+}
+
+/** Wipe the PERSISTED detailing, which is the only absence a test can create. */
+const wipe = (page: Page) => page.evaluate(() =>
+ (window as unknown as Hooks).__stabileoActions.seedDetailing([]));
+
+for (const locale of ['en', 'es', 'pt'] as const) {
+ test.describe(`@slow absence, in ${locale}`, () => {
+ test.slow();
+ test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } });
+
+ test('no detailing at all: the stage says so, and offers nothing to export',
+ async ({ pro: page }) => {
+ await generate(page);
+ await openDocumentsStage(page);
+ await expect(page.getByTestId('documents-stage')).toBeVisible();
+
+ await wipe(page);
+ await expect(page.getByTestId('documents-empty')).toBeVisible();
+ await expect(page.getByTestId('documents-stage')).toHaveCount(0);
+
+ /*
+ * A sentence, and no controls. The source comment says what this state is for — "Not a
+ * blank stage: the reason there is nothing to export, and where to get one" — so the
+ * assertion is that it reads as a reason, in every language.
+ */
+ const text = (await page.getByTestId('documents-empty').innerText()).trim();
+ expect(text.length, 'it explains rather than showing a blank').toBeGreaterThan(20);
+ for (const id of ['doc-xlsx', 'doc-dxf', 'doc-report', 'doc-3d']) {
+ await expect(page.getByTestId(id), `${id} is not offered`).toHaveCount(0);
+ }
+ // And not one figure: an absence is not a zero.
+ expect(text, 'no fabricated count').not.toMatch(/\b0\b/);
+ });
+
+ test('detailing but no document: a different sentence, and the exports ARE offered',
+ async ({ pro: page }) => {
+ await generate(page);
+ await openDocumentsStage(page);
+ await expect(page.getByTestId('doc-none')).toBeVisible();
+ await expect(page.getByTestId('documents-empty')).toHaveCount(0);
+ const text = (await page.getByTestId('doc-none').innerText()).trim();
+ expect(text.length).toBeGreaterThan(5);
+ // The distinction that matters: here the first export is what builds it.
+ await expect(page.getByTestId('doc-xlsx')).toBeEnabled();
+ });
+
+ test('and the two sentences are not the same sentence', async ({ pro: page }) => {
+ await generate(page);
+ await openDocumentsStage(page);
+ const noDocument = (await page.getByTestId('doc-none').innerText()).trim();
+ await wipe(page);
+ const noDetailing = (await page.getByTestId('documents-empty').innerText()).trim();
+ /*
+ * The whole point of this file. Two absences with one message would be the same defect the
+ * floor families had: "we looked and found none" printed identically to "nobody looked".
+ */
+ expect(noDetailing).not.toBe(noDocument);
+ });
+ });
+}
+
+test.describe('@slow the rail names the families that have nothing', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('an empty viewer says which families, not that the workspace is empty',
+ async ({ pro: page }) => {
+ await loadModel(page, 'rc-design-qa-8');
+ await designAll(page);
+ await openDocumentsStage(page);
+ await page.getByTestId('doc-3d').click();
+ await expect(page.getByTestId('rebar-workspace')).toBeVisible();
+
+ await expect(page.getByTestId('rebar-empty-families')).toBeVisible();
+ const text = (await page.getByTestId('rebar-empty-families').innerText()).trim();
+ expect(text.length).toBeGreaterThan(5);
+ /*
+ * `rebar-workspace-empty` means the workspace has nothing at all;
+ * `rebar-empty-families` means these particular families do. Only one of them is true
+ * here, and showing the other would overstate the absence.
+ */
+ await expect(page.getByTestId('rebar-workspace-empty')).toHaveCount(0);
+ });
+});
+
+test.describe('@slow no absence is ever dressed as a verified result', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('nothing on an empty documents stage claims VERIFIED', async ({ pro: page }) => {
+ await generate(page);
+ await openDocumentsStage(page);
+ await wipe(page);
+ /*
+ * Scoped to what the ABSENCE says, not to the whole panel.
+ *
+ * The first version read `.pro-panel` and failed on "not verified" in `DesignOverview`'s
+ * census, "Verified" in a verification chip, and a sentence in the regulations panel about
+ * the code every member is verified against. All three are true statements about other
+ * things on the same screen — the rule is that the empty stage must not claim a verdict, not
+ * that the word may not appear anywhere in the application.
+ */
+ const empty = (await page.getByTestId('documents-empty').innerText()).toLowerCase();
+ for (const claim of ['verified', 'verificado', 'issued', 'emitido', 'constructible']) {
+ expect(empty, `the empty stage must not say "${claim}"`).not.toContain(claim);
+ }
+ // And the elements that would carry a verdict do not exist at all, which is stronger than
+ // their text being careful.
+ for (const id of ['doc-readiness', 'doc-maturity', 'doc-contents', 'review-record',
+ 'doc-revision', 'issue-submit']) {
+ await expect(page.getByTestId(id), `${id} belongs to a document`).toHaveCount(0);
+ }
+ });
+});
+
+/**
+ * `doc-error` is NOT reachable, and the reason is structural.
+ *
+ * `buildDocument` returns null on exactly one condition — `persisted.assemblies.length === 0` —
+ * and `DocumentsSection` renders its whole stage behind `{#if !selected}`, where `selected`
+ * derives from the same list. So the emptiness that would make the build fail also removes the
+ * buttons that would call it. Measured: after `seedDetailing([])` the stage count is 0, the
+ * export buttons are 0, and there is nothing to click.
+ *
+ * `docError = t('detailing.doc.noCoordinated')` is therefore defensive code for a race the
+ * codebase has since designed away — `buildDocument`'s own comment describes the fix, reading
+ * from the PERSISTED store rather than from a `$derived` that "does not necessarily recompute
+ * inside the synchronous turn that wrote its dependency".
+ *
+ * Asserted as unreachable rather than left as an untested branch, and NOT manufactured: forcing
+ * it would mean changing production to make a guard fire.
+ */
+test.describe('@slow the export error branch', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('cannot be reached: emptying the detailing removes the controls that would trigger it',
+ async ({ pro: page }) => {
+ await generate(page);
+ await openDocumentsStage(page);
+ await expect(page.getByTestId('doc-xlsx')).toBeVisible();
+
+ await wipe(page);
+ await expect(page.getByTestId('documents-empty')).toBeVisible();
+ await expect(page.getByTestId('doc-xlsx'), 'no export survives the emptiness')
+ .toHaveCount(0);
+ await expect(page.getByTestId('doc-error'), 'so the error branch never runs')
+ .toHaveCount(0);
+ });
+});
diff --git a/web/e2e/h1e-conflict-states.spec.ts b/web/e2e/h1e-conflict-states.spec.ts
new file mode 100644
index 000000000..51004103e
--- /dev/null
+++ b/web/e2e/h1e-conflict-states.spec.ts
@@ -0,0 +1,311 @@
+/**
+ * The states H1-D could only cover from source, on the fixtures that actually produce them.
+ *
+ * ── What the three RC fixtures produce ─────────────────────────────
+ *
+ * Measured before writing a line of this, on unmodified production:
+ *
+ * rc-design-qa-8 0 conflicts · 0 markers · modelled 9
+ * rc-qa-diagnostic 68 conflicts · 68 markers · modelled 23 · provisional 5
+ * pro-edificio-7p 1318 conflicts · 1310 markers · modelled 194 · provisional 6 · failed 6
+ *
+ * So H1-D's annotation — "conflicts and unreinforced: nothing to filter on rc-design-qa-8" — was
+ * a fixture limitation and not a defect, and `rc-qa-diagnostic` lifts it at 4 s rather than the
+ * 7-storey building's 20 s. `pro-edificio-7p` is used once, for the `failed` state that only it
+ * has.
+ *
+ * ── What no fixture produces, and is therefore NOT covered here ────
+ *
+ * `refused` / unreinforced members — zero in all three.
+ * `doc-error` — all three build a document successfully.
+ * `ConflictInspector` WAS unreachable — it renders from a marker click in the WebGL scene,
+ * which raycasts. `__stabileoActions.selectConflict` now stands in for that click, and the
+ * last describe exercises the panel end to end. It found a focus defect on its first run.
+ *
+ * Each is stated in `docs/handoffs/h1e-fixture-coverage.md` with what it would take. None is
+ * faked, and no engine or solver change was made to manufacture one.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+
+type Hooks = {
+ __stabileo: {
+ detailingAssemblies(): Array<{ conflicts?: unknown[] }>;
+ rebarSceneCensus(): { markers: number };
+ rebarSceneBuilds(): number;
+ };
+};
+
+async function generate(page: Page, model: string) {
+ await loadModel(page, model);
+ 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 as unknown as Hooks).__stabileo.detailingAssemblies().length),
+ { timeout: 180_000 })
+ .toBeGreaterThan(0);
+}
+
+async function openViewer(page: Page) {
+ await openDocumentsStage(page);
+ const before = await page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneBuilds());
+ await page.getByTestId('doc-3d').click();
+ await expect(page.getByTestId('rebar-workspace')).toBeVisible();
+ await expect
+ .poll(() => page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneBuilds()), { timeout: 240_000 })
+ .toBeGreaterThan(before);
+}
+
+const markers = (page: Page) => page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneCensus().markers);
+
+test.describe('@slow conflicts, on a model that has them', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the conflicts layer draws them and hides them', async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openViewer(page);
+
+ /*
+ * The assertion H1-D could not make. It annotated the control as "present, nothing to filter"
+ * and asserted the PREMISE — `markers === 0` on `rc-design-qa-8` — precisely so that this
+ * could replace it rather than the annotation being forgotten.
+ */
+ const drawn = await markers(page);
+ expect(drawn, 'this model draws conflict markers').toBeGreaterThan(0);
+
+ await page.getByTestId('rebar-layer-conflicts').click();
+ await expect.poll(() => markers(page), { timeout: 10_000 }).toBe(0);
+
+ await page.getByTestId('rebar-layer-conflicts').click();
+ await expect.poll(() => markers(page), { timeout: 10_000 }).toBe(drawn);
+ test.info().annotations.push(
+ { type: 'coverage', description: `${drawn} conflict markers toggled off and back` });
+ });
+
+ test('the document reports them and refuses to claim more than a draft',
+ async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openDocumentsStage(page);
+ const download = page.waitForEvent('download', { timeout: 30_000 });
+ await page.getByTestId('doc-xlsx').click();
+ await download;
+
+ /*
+ * `doc-conflicts` is a state H1-C never reached: on `rc-design-qa-8` there are none, so the
+ * block never rendered and the readiness never fell below its best case.
+ */
+ const conflicts = page.getByTestId('doc-conflicts');
+ await expect(conflicts).toBeVisible();
+ expect((await conflicts.innerText()).trim()).toMatch(/\d+/);
+
+ // And the readiness says draft, which is the honest consequence of unresolved conflicts.
+ await expect(page.getByTestId('doc-readiness')).toContainText(/draft|borrador|rascunho/i);
+ });
+
+ test('a provisional member is marked as one in the rail', async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openViewer(page);
+ const provisional = page.locator('.st-provisional');
+ expect(await provisional.count(), 'this model has provisional members').toBeGreaterThan(0);
+ // Violet, and equal to what Three.js paints — the contract asserted in both directions by
+ // `shared-status-tokens.test.ts`, here confirmed on a member that actually has the state.
+ const dot = provisional.first().locator('.dot').first();
+ if (await dot.count()) {
+ const painted = await dot.evaluate((el) => getComputedStyle(el).backgroundColor);
+ const scene = await page.evaluate(() => {
+ const el = document.createElement('span');
+ el.style.color = '#a066d3'; document.body.appendChild(el);
+ const out = getComputedStyle(el).color; el.remove(); return out;
+ });
+ expect(painted, 'the provisional dot is the scene violet').toBe(scene);
+ }
+ });
+});
+
+test.describe('@slow the failed state, on the only model that reaches it', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the 7-storey building shows failed members alongside modelled ones',
+ async ({ pro: page }) => {
+ test.setTimeout(600_000);
+ await generate(page, 'pro-edificio-7p');
+ await openViewer(page);
+
+ const failed = page.locator('.st-failed');
+ expect(await failed.count(), 'only this fixture produces failed members')
+ .toBeGreaterThan(0);
+ // Its dot is the scene's conflicted red, by value.
+ const dot = failed.first().locator('.dot').first();
+ const painted = await dot.evaluate((el) => getComputedStyle(el).backgroundColor);
+ const scene = await page.evaluate(() => {
+ const el = document.createElement('span');
+ el.style.color = '#e0444a'; document.body.appendChild(el);
+ const out = getComputedStyle(el).color; el.remove(); return out;
+ });
+ expect(painted).toBe(scene);
+
+ /*
+ * And the state WORD beside it, which is what makes the colour support rather than the
+ * signal — the rule `RebarStatusPanel` states for itself and that `floor-family-states`
+ * asserts for the floor card.
+ */
+ const label = failed.first().locator('.st, .label').first();
+ expect((await label.innerText()).trim().length).toBeGreaterThan(1);
+
+ expect(await markers(page), 'and it draws its conflicts').toBeGreaterThan(0);
+ });
+});
+
+test.describe('@slow what these fixtures cannot reach', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('no RC fixture produces a refused member, and the premise is asserted',
+ async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openViewer(page);
+ /*
+ * `refused` is zero on all three RC fixtures. Asserted rather than annotated, so that the
+ * day a fixture DOES produce one this fails and the unreinforced filter and the
+ * `.unreinforced` block get exercised instead of staying source-only.
+ */
+ expect(await page.locator('.st-refused').count(), 'still no refused member').toBe(0);
+ expect(await page.locator('.unreinforced').count(), 'and no unreinforced block').toBe(0);
+ // The controls are still offered, which is correct — they are not dead, they are unused.
+ await expect(page.getByTestId('rebar-hide-unreinforced')).toBeAttached();
+ });
+
+ test('the inspector is closed until a marker is chosen', async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openViewer(page);
+ expect(await markers(page), 'the markers are there to be chosen').toBeGreaterThan(0);
+ await expect(page.getByTestId('rebar-conflict-warning'),
+ 'and nothing claims a conflict until one is').toHaveCount(0);
+ });
+});
+
+/**
+ * The conflict inspector, end to end.
+ *
+ * It renders from `selection.conflict`, which only a marker click sets — raycast against the
+ * canvas, at a screen position no test can compute reliably. `__stabileoActions.selectConflict`
+ * is the test mutator that stands in for that click: it resolves the SLOT through the scene's own
+ * `conflictAt`, so what gets selected is what is actually drawn there, not an index into a list
+ * the test rebuilt for itself.
+ */
+test.describe('@slow the conflict inspector', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ async function selectFirstConflict(page: Page) {
+ const ok = await page.evaluate(() => (window as unknown as {
+ __stabileoActions: { selectConflict(slot?: number): boolean };
+ }).__stabileoActions.selectConflict(0));
+ expect(ok, 'slot 0 draws a conflict on this model').toBe(true);
+ }
+
+ test('the band, the ids and the two measurements', async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openViewer(page);
+ await selectFirstConflict(page);
+
+ // The band that says this is not constructible — the sentence, not a colour.
+ const band = page.getByTestId('rebar-conflict-warning');
+ await expect(band).toBeVisible();
+ expect((await band.innerText()).trim().length).toBeGreaterThan(20);
+
+ // Both bars named separately, because "A/B are 14 mm apart" is a measurement and
+ // "bar c12-4 in column 12" is a thing you can go and look at.
+ for (const id of ['rebar-conflict-bar-a', 'rebar-conflict-bar-b']) {
+ expect((await page.getByTestId(id).innerText()).trim().length,
+ `${id} names a bar`).toBeGreaterThan(0);
+ }
+ expect(await page.getByTestId('rebar-conflict-bar-a').innerText())
+ .not.toBe(await page.getByTestId('rebar-conflict-bar-b').innerText());
+
+ // Measured against required, which is what makes it a verdict rather than an opinion.
+ for (const id of ['rebar-conflict-measured', 'rebar-conflict-required']) {
+ expect((await page.getByTestId(id).innerText()).trim()).toMatch(/-?[\d.,]+/);
+ }
+ await expect(page.getByTestId('rebar-conflict-class')).toBeVisible();
+ });
+
+ test('severity is carried by the text, not only by the hue', async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openViewer(page);
+
+ /*
+ * `.head.overlap strong` takes `--st-danger` and the base head takes `--st-text`; the
+ * distinction between interpenetration and a spacing shortfall is what those two levels
+ * exist for. Whichever this slot is, the severity must be READABLE — the colour is support.
+ */
+ const seen = new Set();
+ for (let slot = 0; slot < 6; slot++) {
+ const ok = await page.evaluate((s) => (window as unknown as {
+ __stabileoActions: { selectConflict(slot?: number): boolean };
+ }).__stabileoActions.selectConflict(s), slot);
+ if (!ok) continue;
+ const head = page.locator('.head').first();
+ const text = (await head.innerText()).trim();
+ expect(text.length, `slot ${slot} states its severity in words`).toBeGreaterThan(2);
+ seen.add(text.split(/\s+/)[0]);
+ }
+ expect(seen.size, 'at least one severity was read').toBeGreaterThan(0);
+ test.info().annotations.push(
+ { type: 'coverage', description: `severities seen: ${[...seen].join(', ')}` });
+ });
+
+ test('centre and isolate both act, and neither loses the keyboard', async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openViewer(page);
+ await selectFirstConflict(page);
+
+ const centre = page.getByTestId('rebar-conflict-centre');
+ await expect(centre).toBeVisible();
+ await centre.focus();
+ await centre.click();
+ expect(await page.evaluate(() => document.activeElement === document.body),
+ 'centring does not drop focus to the body').toBe(false);
+
+ const isolate = page.getByTestId('rebar-conflict-isolate');
+ await expect(isolate).toBeVisible();
+ const censusBefore = await page.evaluate(() => JSON.stringify(
+ (window as unknown as { __stabileo: { rebarSceneCensus(): unknown } })
+ .__stabileo.rebarSceneCensus()));
+ await isolate.click();
+
+ // Isolating the pair changes the scene, and offers the way back.
+ await expect(page.getByTestId('rebar-conflict-clear-isolation')).toBeVisible();
+ expect(await page.evaluate(() => JSON.stringify(
+ (window as unknown as { __stabileo: { rebarSceneCensus(): unknown } })
+ .__stabileo.rebarSceneCensus())), 'the scene isolated the pair').not.toBe(censusBefore);
+ expect(await page.evaluate(() => document.activeElement === document.body),
+ 'and isolating does not drop focus either').toBe(false);
+
+ await page.getByTestId('rebar-conflict-clear-isolation').click();
+ await expect(page.getByTestId('rebar-conflict-isolate'), 'and it comes back').toBeVisible();
+ });
+
+ test('Escape still leaves, with a conflict selected', async ({ pro: page }) => {
+ await generate(page, 'rc-qa-diagnostic');
+ await openViewer(page);
+ await selectFirstConflict(page);
+ await expect(page.getByTestId('rebar-conflict-warning')).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(page.getByTestId('rebar-workspace')).toHaveCount(0);
+ expect(await page.evaluate(() =>
+ document.activeElement?.getAttribute('data-testid')), 'and returns to the opener')
+ .toBe('doc-3d');
+ await expect(page.getByTestId('documents-stage')).toBeVisible();
+ });
+});
diff --git a/web/e2e/h1e-rail-and-section.spec.ts b/web/e2e/h1e-rail-and-section.spec.ts
new file mode 100644
index 000000000..2b14f05f7
--- /dev/null
+++ b/web/e2e/h1e-rail-and-section.spec.ts
@@ -0,0 +1,277 @@
+/**
+ * H1-E — the last three of the viewer: the section cut, the narrow rail, and per-family display.
+ *
+ * These are the items `h1e-fixture-coverage.md` §4 listed as still open. Each is exercised
+ * against the drawn scene, not against the control's own state: a `` that changes its own
+ * value proves nothing about whether anything was cut.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+
+type Hooks = {
+ __stabileo: {
+ detailingAssemblies(): unknown[];
+ rebarSceneBuilds(): number;
+ rebarSceneCensus(): { bars: Record; solids: Record };
+ };
+};
+
+async function openViewer(page: Page, model = 'rc-design-qa-8') {
+ await loadModel(page, model);
+ 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 as unknown as Hooks).__stabileo.detailingAssemblies().length), { timeout: 120_000 })
+ .toBeGreaterThan(0);
+ await openDocumentsStage(page);
+ const before = await page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneBuilds());
+ await page.getByTestId('doc-3d').click();
+ await expect(page.getByTestId('rebar-workspace')).toBeVisible();
+ await expect
+ .poll(() => page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneBuilds()), { timeout: 240_000 })
+ .toBeGreaterThan(before);
+}
+
+const census = (page: Page) => page.evaluate(() =>
+ JSON.stringify((window as unknown as Hooks).__stabileo.rebarSceneCensus()));
+
+test.describe('@slow the section cut', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ /**
+ * The cut is a CLIPPING PLANE, not a filter.
+ *
+ * `RebarViewport3D` sets `renderer.localClippingEnabled = true` and clips at material level, so
+ * no mesh is removed and the census does not move. The first version of this asserted the
+ * census changed and failed on all three axes — the same mistake as reading the census for an
+ * opacity change, and worth recording rather than quietly rewriting: the instrument was wrong,
+ * not the feature.
+ *
+ * What IS observable from the DOM is the dependent control: choosing an axis brings up the
+ * position slider, and choosing none takes it away. That is the state the panel owns, and it is
+ * what these assert. The clipped pixels are not reachable without a WebGL readback.
+ */
+ test('choosing an axis brings up its position, and choosing none removes it',
+ async ({ pro: page }) => {
+ await openViewer(page);
+ const axis = page.getByTestId('rebar-section-axis');
+ await expect(axis).toBeAttached();
+ await expect(page.getByTestId('rebar-section-at'), 'no cut, no position')
+ .toHaveCount(0);
+
+ await axis.selectOption('x');
+ await expect(page.getByTestId('rebar-section-at')).toBeAttached();
+ expect(await axis.inputValue()).toBe('x');
+
+ await axis.selectOption('');
+ await expect(page.getByTestId('rebar-section-at'), 'and it goes away again')
+ .toHaveCount(0);
+ });
+
+ test('the position runs across the model, not across an arbitrary range',
+ async ({ pro: page }) => {
+ await openViewer(page);
+ await page.getByTestId('rebar-section-axis').selectOption('y');
+ const at = page.getByTestId('rebar-section-at');
+ await expect(at).toBeAttached();
+
+ /*
+ * The bounds come from the scene, so they say something real: a cut that could only travel
+ * over a fixed 0..1 would miss most of a building. Measured on `rc-design-qa-8`, the y range
+ * spans about 5.4 m with a margin either side.
+ */
+ const min = Number(await at.getAttribute('min'));
+ const max = Number(await at.getAttribute('max'));
+ expect(Number.isFinite(min) && Number.isFinite(max), 'real bounds').toBe(true);
+ expect(max - min, 'the cut spans the model').toBeGreaterThan(1);
+
+ /*
+ * Set through the DOM rather than `fill()`. A `type="range"` rejects a value off its step —
+ * "Malformed value" — and the step here is derived from the span, so a computed 80 % lands
+ * between stops.
+ */
+ const target = min + (max - min) * 0.8;
+ await at.evaluate((el, v) => {
+ const input = el as HTMLInputElement;
+ input.value = String(v);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ }, target);
+ expect(Number(await at.inputValue()), 'the slider took the position')
+ .toBeGreaterThan(min + (max - min) * 0.5);
+ });
+
+ test('all three axes are offered and each is selectable', async ({ pro: page }) => {
+ await openViewer(page);
+ const axis = page.getByTestId('rebar-section-axis');
+ for (const a of ['x', 'y', 'z']) {
+ await axis.selectOption(a);
+ expect(await axis.inputValue(), `${a} is a real option`).toBe(a);
+ await expect(page.getByTestId('rebar-section-at'),
+ `${a} has a position`).toBeAttached();
+ }
+ await axis.selectOption('');
+ expect(await axis.inputValue()).toBe('');
+ });
+});
+
+test.describe('@slow the rail at a narrow viewport', () => {
+ test.slow();
+
+ test('below 860 the toggle appears and actually collapses the rail', async ({ pro: page }) => {
+ /*
+ * Opened AT 820, not resized into it.
+ *
+ * `onResize` sets `railOpen = wide` whenever the width crosses 860 — deliberate, and the
+ * source says why: "The rail is one tap away and starts closed" on mobile. The first version
+ * of this resized after opening and raced that handler: it read the rail as still open,
+ * clicked, and got the state the resize had already decided. A test that fights a documented
+ * behaviour is measuring itself.
+ */
+ await page.setViewportSize({ width: 820, height: 700 });
+ await openViewer(page);
+
+ const toggle = page.getByTestId('rebar-rail-toggle');
+ await expect(toggle, 'the toggle exists at this width, unlike at 1280').toBeVisible();
+
+ const rail = page.getByTestId('rebar-rail');
+ const openBefore = await rail.isVisible();
+ await toggle.click();
+ await expect.poll(() => rail.isVisible(), { timeout: 5_000 }).toBe(!openBefore);
+ await toggle.click();
+ await expect.poll(() => rail.isVisible(), { timeout: 5_000 }).toBe(openBefore);
+ });
+
+ test('the toggle keeps focus and reports its state to a screen reader',
+ async ({ pro: page }) => {
+ await page.setViewportSize({ width: 820, height: 700 });
+ await openViewer(page);
+ const toggle = page.getByTestId('rebar-rail-toggle');
+ await toggle.focus();
+ const before = await toggle.getAttribute('aria-expanded');
+ await toggle.click();
+ /*
+ * `aria-expanded` is the whole accessible payload of this control — it has no label but a
+ * glyph — so if it does not move, a screen-reader user cannot tell the rail closed.
+ */
+ expect(await toggle.getAttribute('aria-expanded'), 'the state is announced')
+ .not.toBe(before);
+ expect(await page.evaluate(() =>
+ document.activeElement?.getAttribute('data-testid')), 'and focus survives the toggle')
+ .toBe('rebar-rail-toggle');
+ });
+
+ test('the workspace still fits, and the canvas is not squeezed out',
+ async ({ pro: page }) => {
+ await page.setViewportSize({ width: 820, height: 700 });
+ await openViewer(page);
+ const box = await page.getByTestId('rebar-workspace')
+ .evaluate((el) => ({ scroll: el.scrollWidth, client: el.clientWidth }));
+ expect(box.scroll, 'no sideways scroll at 820').toBeLessThanOrEqual(box.client + 1);
+ /*
+ * The rail becomes a sheet OVER the canvas at this width, and the source comment says why:
+ * "A 17 rem column on a 390 px screen leaves the viewport unusable, and the viewport is the
+ * reason the workspace exists." So the canvas must still have real area.
+ */
+ const canvas = await page.getByTestId('rebar-canvas').boundingBox();
+ expect(canvas!.width, 'the viewport keeps its width').toBeGreaterThan(400);
+ expect(canvas!.height).toBeGreaterThan(200);
+ });
+});
+
+test.describe('@slow per-family display', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('each family that HAS geometry can be switched off on its own',
+ async ({ pro: page }) => {
+ await openViewer(page);
+ const c = JSON.parse(await census(page)) as {
+ bars: Record; solids: Record;
+ };
+ const present = Object.keys(c.bars).filter((k) => c.bars[k] > 0);
+ expect(present.length, 'this model draws at least one family').toBeGreaterThan(0);
+
+ const toggled: string[] = [];
+ for (const family of present) {
+ const control = page.getByTestId(`rebar-layer-${family}`);
+ if (!(await control.count())) continue;
+ const before = await census(page);
+ await control.click();
+ await expect.poll(() => census(page), { timeout: 10_000 }).not.toBe(before);
+ await control.click();
+ await expect.poll(() => census(page), { timeout: 10_000 }).toBe(before);
+ toggled.push(family);
+ }
+ expect(toggled.length, 'and at least one of them is switchable').toBeGreaterThan(0);
+ test.info().annotations.push(
+ { type: 'coverage', description: `families toggled: ${toggled.join(', ')}` });
+ });
+
+ test('a family with nothing in it is named as empty, not hidden',
+ async ({ pro: page }) => {
+ await openViewer(page);
+ const c = JSON.parse(await census(page)) as { bars: Record };
+ const absent = Object.keys(c.bars).filter((k) => c.bars[k] === 0);
+ expect(absent.length, 'this model has families with nothing in them')
+ .toBeGreaterThan(0);
+
+ /*
+ * The rule this branch has applied everywhere: an absence is stated, not left blank. The
+ * empty families are listed by name in `rebar-empty-families`, and their layer rows carry
+ * an `.empty` mark rather than disappearing — a family you cannot see is a family you
+ * cannot ask about.
+ */
+ const listed = page.getByTestId('rebar-empty-families');
+ await expect(listed).toBeVisible();
+ const text = (await listed.innerText()).toLowerCase();
+ const named = absent.filter((f) => text.includes(f));
+ expect(named.length, `the empty families are named — "${text.slice(0, 60)}"`)
+ .toBeGreaterThan(0);
+ test.info().annotations.push({
+ type: 'coverage',
+ description: `${absent.length} empty families, ${named.length} named`,
+ });
+ });
+
+ test('the tally reports every family the scene draws', async ({ pro: page }) => {
+ await openViewer(page);
+ const tally = page.getByTestId('rebar-tally');
+ await expect(tally).toBeVisible();
+
+ /*
+ * The tally splits each family into solids, longitudinal and transverse — it is not the
+ * census's bar count, and asserting `toContain('200')` against it was reading one number
+ * expecting another. Its own comment says what it is for: "12 705 bars looked full while
+ * every column tie in the building was absent. Lots of bars and all the bars are
+ * indistinguishable by eye."
+ *
+ * So the assertion is per FAMILY ROW, against the families the census says are drawn.
+ */
+ const c = JSON.parse(await census(page)) as { bars: Record };
+ const drawn = Object.keys(c.bars).filter((k) => c.bars[k] > 0);
+ expect(drawn.length).toBeGreaterThan(0);
+
+ for (const family of drawn) {
+ const row = page.getByTestId(`rebar-tally-${family}`);
+ await expect(row, `${family} has a row`).toBeVisible();
+ const cells = (await row.innerText()).match(/\d+/g) ?? [];
+ expect(cells.length, `${family} states figures`).toBeGreaterThan(1);
+ }
+
+ // And the headline bar count agrees with the census total, which is the drift this guards.
+ const total = Object.values(c.bars).reduce((a, b) => a + b, 0);
+ expect((await tally.innerText()).replace(/[\u00a0\u202f\s]/g, ''),
+ 'the total matches what is drawn').toContain(String(total));
+ test.info().annotations.push(
+ { type: 'coverage', description: `families in the tally: ${drawn.join(', ')}` });
+ });
+});
diff --git a/web/e2e/h1e-refused-state.spec.ts b/web/e2e/h1e-refused-state.spec.ts
new file mode 100644
index 000000000..0adb58325
--- /dev/null
+++ b/web/e2e/h1e-refused-state.spec.ts
@@ -0,0 +1,232 @@
+/**
+ * A member the design REFUSES, produced rather than fabricated.
+ *
+ * ── Why a fixture had to be built at all ───────────────────────────
+ *
+ * None of the three RC examples reaches `REFUSED`. Measured: `rc-design-qa-8` designs to
+ * `VERIFIED` ×8, `rc-qa-diagnostic` to `VERIFIED` ×22 + `PROVISIONAL_BIAXIAL` ×8, and
+ * `pro-edificio-7p` to `VERIFIED` ×198 + `PROVISIONAL_BIAXIAL` ×10. So the state, its filter and
+ * the unreinforced block had never been exercised in a browser.
+ *
+ * ── How this one is made, and what it is NOT ───────────────────────
+ *
+ * `__stabileoActions.updateSection` shrinks ONE section — `RC Col 400×400`, section id 2, down to
+ * 90 × 120 mm — and the design runs again. What follows is the real engine's verdict:
+ * `SEARCH_EXHAUSTED` ×8, alongside `VERIFIED` ×4 on the members that use other sections.
+ *
+ * This fixture is what found that `REFUSED` was unreachable: `FAILED` was tested first and a
+ * refused member also fails verification, so the rail said `failed 5 · refused 0` while the
+ * design table said `SEARCH_EXHAUSTED` ×8. The classifier now lets an outcome that already
+ * explains its own failure through, and these assert the corrected behaviour.
+ *
+ * Nothing writes a state. The engine enumerates the whole code-permitted reinforcement envelope
+ * for a column that cannot carry its demand, finds nothing that verifies, and says so — which is
+ * exactly what `candidate-search.ts` documents as the honest distinction between "exhausted" and
+ * "infeasible".
+ *
+ * Section 2 and not section 1: id 1 is unused by the designed members, and shrinking it changes
+ * nothing. Found by walking the ids, and worth recording so the next person does not repeat it.
+ * 90 × 120 mm and not 50 × 60: the whole-model shrink took the design past a ten-minute budget,
+ * because the search enumerates a far larger envelope when nothing fits at all.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+
+type Hooks = {
+ __stabileo: { detailingAssemblies(): unknown[]; rebarSceneBuilds(): number };
+ __stabileoActions: { updateSection(id: number, data: unknown): void };
+};
+
+/** The section the columns use, too small for their demand. */
+const STARVED = { b: 0.09, h: 0.12 };
+
+async function withRefusedMembers(page: Page) {
+ await loadModel(page, 'rc-design-qa-8');
+ await designAll(page);
+ await page.evaluate((d) => {
+ (window as unknown as Hooks).__stabileoActions.updateSection(2, d);
+ }, STARVED);
+ await designAll(page);
+ await page.getByTestId('pr-stage-design').click();
+ await page.getByTestId('pr-cmd-design').click();
+}
+
+const outcomes = (page: Page) => page.evaluate(() => {
+ const m: Record = {};
+ for (const el of document.querySelectorAll('[data-outcome]')) {
+ const o = el.getAttribute('data-outcome') || '(none)';
+ m[o] = (m[o] ?? 0) + 1;
+ }
+ return m;
+});
+
+test.describe('@slow the design refuses, and the app says so', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the engine reaches SEARCH_EXHAUSTED on its own', async ({ pro: page }) => {
+ await loadModel(page, 'rc-design-qa-8');
+ await designAll(page);
+ await page.getByTestId('pr-stage-design').click();
+ await page.getByTestId('pr-cmd-design').click();
+ const before = await outcomes(page);
+ expect(before.VERIFIED, 'the untouched fixture verifies everything').toBeGreaterThan(0);
+ expect(before.SEARCH_EXHAUSTED ?? 0, 'and refuses nothing').toBe(0);
+
+ await page.evaluate((d) => {
+ (window as unknown as Hooks).__stabileoActions.updateSection(2, d);
+ }, STARVED);
+ await designAll(page);
+ await page.getByTestId('pr-stage-design').click();
+ await page.getByTestId('pr-cmd-design').click();
+
+ const after = await outcomes(page);
+ expect(after.SEARCH_EXHAUSTED ?? 0, 'a starved column is refused').toBeGreaterThan(0);
+ // Both halves: the refusal is real AND it is not total. A run that refused everything would
+ // be a broken model, not a refused member.
+ expect(after.VERIFIED ?? 0, 'the members on other sections still verify').toBeGreaterThan(0);
+ });
+
+ test('the rail says REFUSED, and gives the remedy the refusal calls for',
+ async ({ pro: page }) => {
+ await withRefusedMembers(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 as unknown as Hooks).__stabileo.detailingAssemblies().length),
+ { timeout: 120_000 })
+ .toBeGreaterThan(0);
+ await openDocumentsStage(page);
+ const builds = await page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneBuilds());
+ await page.getByTestId('doc-3d').click();
+ await expect(page.getByTestId('rebar-workspace')).toBeVisible();
+ await expect
+ .poll(() => page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneBuilds()), { timeout: 240_000 })
+ .toBeGreaterThan(builds);
+
+ /**
+ * What this fixture was built to reach, and could not until the classifier was corrected.
+ *
+ * `FAILED` used to preempt every refusal, because a refused member also fails verification.
+ * It now preempts only when the outcome does not already name the reason. The two states
+ * mean different remedies — change the section, versus change the reinforcement — so the
+ * rail was sending the reader to a fix that could not work.
+ */
+ const refused = page.locator('.st-refused');
+ expect(await refused.count(), 'the refusal is reported as one').toBeGreaterThan(0);
+
+ const row = refused.first();
+ const text = (await row.locator('.st, .label').first().innerText()).trim();
+ expect(text.length, 'the state is a word, not only a colour').toBeGreaterThan(1);
+ expect(text.toLowerCase(), 'and it does not claim success')
+ .not.toMatch(/verified|verificado|modelled|modelado/);
+
+ /*
+ * The glyph, by value against the scene: `unreinforced: 0xd4762a`. A refused member carries
+ * no steel, so this is the colour the viewport paints its concrete with, and the rail must
+ * agree — the mirror `shared-status-tokens.test.ts` asserts in both directions, here on a
+ * member that actually has the state.
+ */
+ const painted = await row.locator('.dot').first()
+ .evaluate((el) => getComputedStyle(el).backgroundColor);
+ const scene = await page.evaluate(() => {
+ const el = document.createElement('span');
+ el.style.color = '#d4762a'; document.body.appendChild(el);
+ const out = getComputedStyle(el).color; el.remove(); return out;
+ });
+ expect(painted, 'the refused dot is the unreinforced orange, not the conflicted red')
+ .toBe(scene);
+ });
+
+ test('the member loses its steel, and the unreinforced block names it',
+ async ({ pro: page }) => {
+ await withRefusedMembers(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 as unknown as Hooks).__stabileo.detailingAssemblies().length),
+ { timeout: 120_000 })
+ .toBeGreaterThan(0);
+ await openDocumentsStage(page);
+ const builds = await page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneBuilds());
+ await page.getByTestId('doc-3d').click();
+ await expect(page.getByTestId('rebar-workspace')).toBeVisible();
+ await expect
+ .poll(() => page.evaluate(() =>
+ (window as unknown as Hooks).__stabileo.rebarSceneBuilds()), { timeout: 240_000 })
+ .toBeGreaterThan(builds);
+
+ /*
+ * The measurable consequence of a refusal, and the one that matters on screen: the columns
+ * keep their concrete and lose their steel. Census before the shrink was 200 column bars;
+ * after it is 0, with the four solids still drawn.
+ */
+ const c = await page.evaluate(() => (window as unknown as {
+ __stabileo: { rebarSceneCensus(): { bars: Record;
+ solids: Record } };
+ }).__stabileo.rebarSceneCensus());
+ expect(c.bars.column, 'a refused column carries no steel').toBe(0);
+ expect(c.solids.column, 'and still shows its concrete').toBeGreaterThan(0);
+
+ /*
+ * `RebarScenePanel`'s `.unreinforced` block. H1-D asserted it ABSENT with the premise
+ * `refused === 0`; this is the other side of that assertion, and the reason it was written
+ * as a premise rather than an annotation. It lives in the WORKSPACE — checking for it in
+ * the design panel, as a first version did, finds nothing and proves nothing.
+ */
+ const block = page.locator('.unreinforced');
+ expect(await block.count(), 'the block that names them').toBeGreaterThan(0);
+ expect((await block.first().innerText()).trim().length,
+ 'and it explains rather than listing ids').toBeGreaterThan(20);
+ });
+});
+
+test.describe('@slow a refusal is never dressed as a result', () => {
+ test.slow();
+ test.use({ viewport: { width: 1280, height: 720 } });
+
+ test('the refused member carries no certificate and no verified badge',
+ async ({ pro: page }) => {
+ await withRefusedMembers(page);
+ /*
+ * The standing rule of this branch, on the state most tempting to soften: a member the
+ * design could not solve must not appear anywhere as checked. Read on the ROW that carries
+ * the refusal, not on the panel — other members on other sections verify correctly and
+ * their badges are true.
+ */
+ const row = page.locator('[data-outcome="SEARCH_EXHAUSTED"]').first();
+ await expect(row).toBeVisible();
+ const text = (await row.innerText()).toLowerCase();
+ for (const claim of ['verified', 'verificado', 'certified', 'certificado']) {
+ expect(text, `a refused member must not read "${claim}"`).not.toContain(claim);
+ }
+ });
+
+ test('and the counts separate it from a failure and from a verified member',
+ async ({ pro: page }) => {
+ await withRefusedMembers(page);
+ const after = await outcomes(page);
+ /*
+ * REFUSED and FAILED are different things — "the design ran and could not find a passing
+ * arrangement" against "the verification ran and the member does not pass" — and both are
+ * different from VERIFIED. A census that merged any two would hide which question to ask
+ * next: change the section, or change the reinforcement.
+ */
+ expect(Object.keys(after).length, 'more than one outcome on screen')
+ .toBeGreaterThan(1);
+ expect(after.SEARCH_EXHAUSTED ?? 0).toBeGreaterThan(0);
+ expect(after.VERIFIED ?? 0).toBeGreaterThan(0);
+ test.info().annotations.push(
+ { type: 'coverage', description: `outcomes: ${JSON.stringify(after)}` });
+ });
+});
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/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/e2e/viewer-typography.spec.ts b/web/e2e/viewer-typography.spec.ts
new file mode 100644
index 000000000..d5135e2fa
--- /dev/null
+++ b/web/e2e/viewer-typography.spec.ts
@@ -0,0 +1,206 @@
+/**
+ * The 3-D workspace renders in the application's typeface.
+ *
+ * ── The defect H1-A measured ───────────────────────────────────────
+ *
+ * `App.svelte:1196` mounts ` ` as a SIBLING of `.app-container`, and that
+ * container is the only element in the application declaring `font-family: var(--st-sans)`. So
+ * the overlay inherited `-apple-system` from `index.html` and rendered in San Francisco on a Mac
+ * and Segoe on Windows, while every panel behind it rendered in IBM Plex:
+ *
+ * insideAppContainer: false · wsFont: -apple-system · appFont: "IBM Plex Sans" · wsMono: false
+ *
+ * ── Why these assertions and not a screenshot ──────────────────────
+ *
+ * A screenshot of the viewer in the wrong font looks like a viewer, not like a bug — that is how
+ * this survived a colour audit that explicitly set out to find why the viewer "looked like a
+ * different application". So the font is compared as a RESOLVED family against what
+ * `.app-container` resolves to, which is the only way to say "the same font" rather than "a
+ * font".
+ *
+ * And the mount point is asserted to stay OUTSIDE `.app-container`, because that is not the bug:
+ * the launcher lives in `aside.pro-sidebar`, whose fixed pixel width is what made the viewer a
+ * few hundred pixels wide. A future fix that "tidies" the overlay back into the container would
+ * pass a font test and undo the reason the overlay exists.
+ */
+
+import { test, expect, designAll, loadModel, openDocumentsStage } from './fixtures';
+import type { Page } from '@playwright/test';
+
+const first = (family: string) => family.split(',')[0].trim().replace(/^["']|["']$/g, '');
+
+async function openWorkspace(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);
+
+ 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);
+}
+
+for (const [w, h] of [[1280, 720], [1024, 700]] as const) {
+ test.describe(`@slow the viewer's typeface at ${w}×${h}`, () => {
+ test.slow();
+ test.use({ viewport: { width: w, height: h } });
+
+ test('the overlay renders in the same family the application does',
+ async ({ pro: page }) => {
+ await openWorkspace(page);
+ const r = await page.evaluate(() => {
+ const ws = document.querySelector('[data-testid="rebar-workspace"]') as HTMLElement;
+ const app = document.querySelector('.app-container') as HTMLElement | null;
+ return {
+ outsideContainer: app ? !app.contains(ws) : null,
+ ws: getComputedStyle(ws).fontFamily,
+ app: app ? getComputedStyle(app).fontFamily : null,
+ token: getComputedStyle(document.documentElement)
+ .getPropertyValue('--st-sans').trim(),
+ };
+ });
+
+ // The mount point stays where it is, on purpose. See the file header.
+ expect(r.outsideContainer, 'the overlay is still mounted outside .app-container')
+ .toBe(true);
+ expect(first(r.ws), 'the same family, not merely a nice one').toBe(first(r.app!));
+ expect(first(r.ws)).toBe(first(r.token));
+ // The negative: the system stack it used to fall through to.
+ expect(first(r.ws)).not.toBe('-apple-system');
+ });
+
+ test('every figure column in the rail is the mono family, with tabular digits',
+ async ({ pro: page }) => {
+ await openWorkspace(page);
+ const mono = first(await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--st-mono').trim()));
+
+ /*
+ * The selectors their own authors had already marked `tabular-nums`, plus
+ * `SelectionDetails`'s `dd`, which reports the same kind of measured value as
+ * `ConflictInspector`'s and had neither declaration.
+ *
+ * `tabular-nums` alone was not enough and that is the point: it asks the CURRENT font
+ * for tabular figures, and the font was whatever the OS supplied.
+ */
+ const targets = [
+ ['.n', 'a state count'],
+ ['.cause-n', 'a cause count'],
+ ['.tally td', 'the family tally'],
+ ] as const;
+
+ const measured: string[] = [];
+ for (const [sel, what] of targets) {
+ const el = page.getByTestId('rebar-workspace').locator(sel).first();
+ if (!(await el.count())) continue;
+ measured.push(what);
+ const cs = await el.evaluate((n) => {
+ const s = getComputedStyle(n);
+ return { family: s.fontFamily, variant: s.fontVariantNumeric };
+ });
+ expect(first(cs.family), `${what} (${sel}) is the mono family`).toBe(mono);
+ expect(cs.variant, `${what} (${sel}) asks for tabular digits`)
+ .toContain('tabular-nums');
+ }
+ expect(measured.length, 'at least one figure column is on screen').toBeGreaterThan(0);
+ test.info().annotations.push(
+ { type: 'coverage', description: `columns measured: ${measured.join(', ')}` });
+ });
+
+ test('a state WORD is not put in the mono family', async ({ pro: page }) => {
+ await openWorkspace(page);
+ /*
+ * `.n` and `.st` shared one rule, so the count and the translated state word had the same
+ * declaration — and `tabular-nums` had never done anything for a word. Splitting them is
+ * the kind of change that is invisible unless something checks the negative.
+ */
+ const st = page.getByTestId('rebar-workspace').locator('.st').first();
+ if (!(await st.count())) return;
+ const mono = first(await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--st-mono').trim()));
+ const sans = first(await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--st-sans').trim()));
+ const family = first(await st.evaluate((n) => getComputedStyle(n).fontFamily));
+ expect(family, 'the state word stays in the text face').toBe(sans);
+ expect(family).not.toBe(mono);
+ });
+
+ test('no control inside the overlay falls back to the UA font', async ({ pro: page }) => {
+ await openWorkspace(page);
+ /**
+ * The finding that made this change bigger than one declaration.
+ *
+ * `.app-container`'s comment says one declaration "reaches every descendant that does not
+ * override it", and that is true of INHERITANCE — but `button`, `input`, `select` and
+ * `textarea` do not inherit a font: every UA stylesheet gives them their own. Measured
+ * inside this overlay before the fix: 12 buttons and 13 inputs, all Arial, inside a panel
+ * already rendering IBM Plex.
+ *
+ * Counted rather than sampled, because `.first()` would pass on whichever control happened
+ * to declare a family of its own.
+ */
+ const bad = await page.getByTestId('rebar-workspace').evaluate((ws) => {
+ const out: string[] = [];
+ for (const el of ws.querySelectorAll('button,input,select,textarea')) {
+ const fam = getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim();
+ if (fam === 'Arial' || fam === 'Times New Roman' || fam === '-apple-system') {
+ out.push(`${el.tagName.toLowerCase()}[${el.getAttribute('data-testid') ?? '?'}] ${fam}`);
+ }
+ }
+ return out;
+ });
+ expect(bad, 'controls still on the UA font').toEqual([]);
+ });
+
+ test('the overlay colour aliases still resolve — the earlier fix is intact',
+ async ({ pro: page }) => {
+ await openWorkspace(page);
+ // Declaring a font must not have disturbed the four aliases `.workspace` defines, which
+ // are what keeps the viewer's colours on the token system.
+ const r = await page.getByTestId('rebar-workspace').evaluate((el) => {
+ const cs = getComputedStyle(el);
+ return ['--text', '--text-muted', '--st-border', '--panel']
+ .map((n) => [n, cs.getPropertyValue(n).trim()] as const);
+ });
+ for (const [name, value] of r) {
+ expect(value, `${name} still resolves inside the overlay`).not.toBe('');
+ }
+ });
+ });
+}
+
+/**
+ * One assertion per language, because a typeface is not translated but a fallback is a per-script
+ * decision: if IBM Plex ever lacked a glyph the browser would substitute silently, and the family
+ * reported for the ELEMENT would still be Plex. So this checks the rail's own labels render in it
+ * in all three, which is as close as a resolved-family read can get to that question.
+ */
+for (const locale of ['en', 'es', 'pt'] as const) {
+ test.describe(`@slow the rail's labels in ${locale}`, () => {
+ test.slow();
+ test.use({ appLocale: locale, viewport: { width: 1280, height: 720 } });
+
+ test('the rail renders in the application face', async ({ pro: page }) => {
+ await openWorkspace(page);
+ const sans = first(await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--st-sans').trim()));
+ const label = page.getByTestId('rebar-workspace').locator('.label, h4, h5').first();
+ await expect(label).toBeAttached();
+ expect(first(await label.evaluate((n) => getComputedStyle(n).fontFamily))).toBe(sans);
+ });
+ });
+}
diff --git a/web/src/components/pro/ProDesignTab.svelte b/web/src/components/pro/ProDesignTab.svelte
index 172f2cd96..46f35e33d 100644
--- a/web/src/components/pro/ProDesignTab.svelte
+++ b/web/src/components/pro/ProDesignTab.svelte
@@ -463,18 +463,20 @@
.design-tab { display: flex; flex-direction: column; height: 100%; overflow: auto; }
/* A table shorter than this is not a table you can work in; below it, the tab scrolls. */
.design-tab :global(.table-scroll) { min-height: 14rem; }
- .placeholder { padding: 20px; text-align: center; color: var(--st-text-3); font-size: 0.78rem; font-style: italic; }
+ .placeholder { padding: 20px; text-align: center; color: var(--st-text-2); font-size: 0.78rem; font-style: italic; }
.action-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
padding: 4px 12px; background: var(--st-bg); border-bottom: 1px solid var(--st-surface-3); flex-shrink: 0; }
.sel-count { font-size: 0.7rem; color: var(--st-text-2); }
- .muted { color: var(--st-text-3); }
+ .muted { color: var(--st-text-2); }
.act { padding: 2px 9px; background: var(--st-surface-3); border: 1px solid var(--st-info);
border-radius: 3px; color: var(--st-text); font-size: 0.7rem; font-weight: 600; cursor: pointer; }
.act:hover:not(:disabled) { background: var(--st-hair-strong); }
.act:disabled { opacity: 0.4; cursor: not-allowed; }
.act:focus-visible { outline: 2px solid var(--st-value); outline-offset: 1px; }
.act-sm { font-size: 0.66rem; padding: 1px 7px; }
- .hint { margin-left: auto; font-size: 0.64rem; color: var(--st-text-3); font-family: monospace; }
+ /* `--st-text-2`: this is copy, and `--st-text-3` measured 4.03 on `--st-bg` — the ground the
+ tab header sits on, which is darker than a panel and still not enough. */
+ .hint { margin-left: auto; font-size: 0.64rem; color: var(--st-text-2); font-family: monospace; }
.detail-wrap { display: flex; flex-direction: column; gap: 6px; }
.detail-head { display: flex; gap: 6px; }
diff --git a/web/src/components/pro/design/BatchEditDialog.svelte b/web/src/components/pro/design/BatchEditDialog.svelte
index 42b7f0c16..73ebe0616 100644
--- a/web/src/components/pro/design/BatchEditDialog.svelte
+++ b/web/src/components/pro/design/BatchEditDialog.svelte
@@ -319,7 +319,7 @@
h2 { margin: 0; font-size: 0.92rem; color: var(--st-text); }
.x { background: none; border: 1px solid var(--st-hair); color: var(--st-text-2); font-size: 1.2rem; cursor: pointer; line-height: 1; }
.meta { display: flex; gap: 10px; flex-wrap: wrap; font-size: 0.72rem; color: var(--st-text-2); }
- .muted { color: var(--st-text-3); }
+ .muted { color: var(--st-text-2); }
.warn-inline { color: var(--st-text); }
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 6px; }
fieldset { border: 1px solid var(--st-surface-3); border-radius: 4px; padding: 4px 7px 6px; margin: 0; }
@@ -338,8 +338,8 @@
.prow { display: flex; gap: 7px; align-items: center; flex-wrap: wrap;
padding: 1px 0; font-size: 0.66rem; color: var(--st-text); border-bottom: 1px solid var(--st-surface-3); }
.prow.blocked { color: var(--st-text); }
- .prow.nochange { color: var(--st-text-3); }
- .pid { min-width: 34px; } .pkind { min-width: 46px; color: var(--st-text-3); }
+ .prow.nochange { color: var(--st-text-2); }
+ .pid { min-width: 34px; } .pkind { min-width: 46px; color: var(--st-text-2); }
.pblock { flex: 1; } .pchanges { flex: 1; } .putil { color: var(--st-text-2); }
.mono { font-family: monospace; }
.summary { margin-top: 4px; font-size: 0.7rem; color: var(--st-text-2); font-weight: 600; }
diff --git a/web/src/components/pro/design/ChangedMembersPanel.svelte b/web/src/components/pro/design/ChangedMembersPanel.svelte
index 5ab3bd802..ceebde9e0 100644
--- a/web/src/components/pro/design/ChangedMembersPanel.svelte
+++ b/web/src/components/pro/design/ChangedMembersPanel.svelte
@@ -99,8 +99,8 @@
.head { display: flex; align-items: center; gap: 8px; font-size: 0.74rem; color: var(--st-text-2); }
.head.sub { margin-top: 7px; padding-top: 5px; border-top: 1px dashed var(--st-surface-3); }
.x { margin-left: auto; background: none; border: 1px solid var(--st-hair); color: var(--st-text-2); font-size: 1.1rem; cursor: pointer; }
- .muted { color: var(--st-text-3); font-size: 0.68rem; }
- .empty { padding: 6px 0; font-size: 0.7rem; color: var(--st-text-3); font-style: italic; }
+ .muted { color: var(--st-text-2); font-size: 0.68rem; }
+ .empty { padding: 6px 0; font-size: 0.7rem; color: var(--st-text-2); font-style: italic; }
.list { list-style: none; margin: 3px 0 0; padding: 0; }
li { display: flex; align-items: center; gap: 7px; padding: 1px 0;
font-size: 0.68rem; border-bottom: 1px solid var(--st-surface-3); flex-wrap: wrap; }
diff --git a/web/src/components/pro/design/ConflictInspector.svelte b/web/src/components/pro/design/ConflictInspector.svelte
index c60ec0735..7fdf96b0a 100644
--- a/web/src/components/pro/design/ConflictInspector.svelte
+++ b/web/src/components/pro/design/ConflictInspector.svelte
@@ -73,19 +73,28 @@
data-testid="rebar-conflict-centre"
onclick={() => rebarWorkspace.selectConflict(conflict)}
>{t('detailing.scene.conflict.centre')}
- {#if isolated}
- rebarWorkspace.clearIsolation()}
- >{t('detailing.scene.clearIsolation')}
- {:else}
- rebarWorkspace.selectConflict(conflict, { isolateMembers: true })}
- >{tp('detailing.scene.conflict.isolate', { n: conflict.elementIds.length })}
- {/if}
+
+ (isolated
+ ? rebarWorkspace.clearIsolation()
+ : rebarWorkspace.selectConflict(conflict, { isolateMembers: true }))}
+ >
+ {isolated
+ ? t('detailing.scene.clearIsolation')
+ : tp('detailing.scene.conflict.isolate', { n: conflict.elementIds.length })}
+
@@ -93,27 +102,39 @@
.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;
margin: 0; font-size: 0.74rem;
}
dt { color: var(--text-muted, #8b93a3); }
- dd { margin: 0; font-variant-numeric: tabular-nums; }
+ dd { margin: 0; font-family: var(--st-mono); 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/DesignFamilyPanel.svelte b/web/src/components/pro/design/DesignFamilyPanel.svelte
index 7566754c5..d180f16cb 100644
--- a/web/src/components/pro/design/DesignFamilyPanel.svelte
+++ b/web/src/components/pro/design/DesignFamilyPanel.svelte
@@ -345,7 +345,7 @@
.frow label { display: inline-flex; align-items: baseline; gap: 0.35rem; cursor: pointer; min-width: 0; }
.frow input:focus-visible { outline: 2px solid var(--st-value); outline-offset: 1px; }
.fname { color: var(--st-text); }
- .census { color: var(--st-text-3); font-size: 0.68rem; white-space: nowrap; }
+ .census { color: var(--st-text-2); font-size: 0.68rem; white-space: nowrap; }
.fstate { font-size: 0.68rem; font-weight: 600; color: var(--st-text-2); white-space: nowrap; }
/* Colour supports the glyph and the word; it never carries the state alone. */
.frow[data-state='designed'] .fstate { color: var(--st-ok); }
@@ -353,7 +353,7 @@
.frow[data-state='provisional'] .fstate { color: var(--st-warn); }
.frow[data-state='failed'] .fstate { color: var(--st-danger); }
.frow[data-state='noElements'] .fstate,
- .frow[data-state='skipped'] .fstate { color: var(--st-text-3); }
+ .frow[data-state='skipped'] .fstate { color: var(--st-text-2); }
/* The three scopes, so the difference is read rather than discovered by pressing. */
.scopes { margin: 0.35rem 0; display: flex; flex-direction: column; gap: 0.2rem; }
@@ -369,7 +369,7 @@
font-size: 0.7rem;
color: var(--st-text-2);
}
- .hint { margin: 0.3rem 0 0; font-size: 0.66rem; color: var(--st-text-3); line-height: 1.35; }
+ .hint { margin: 0.3rem 0 0; font-size: 0.66rem; color: var(--st-text-2); line-height: 1.35; }
label {
display: flex; align-items: center; gap: 0.3rem;
font-size: 0.78rem; cursor: pointer;
@@ -390,7 +390,7 @@
}
.bulk button:hover { background: var(--st-surface-3); color: var(--st-text); }
.summary { margin: 0; font-size: 0.78rem; }
- .note, .cols { margin: 0; font-size: 0.7rem; color: var(--st-text-3); }
+ .note, .cols { margin: 0; font-size: 0.7rem; color: var(--st-text-2); }
.run {
align-self: flex-start; font-size: 0.74rem; font-weight: 600;
padding: 0.25rem 0.7rem; cursor: pointer;
@@ -404,10 +404,13 @@
table { width: 100%; border-collapse: collapse; font-size: 0.74rem; }
th { text-align: left; font-weight: 400; }
td { text-align: right; font-variant-numeric: tabular-nums; }
- td.state { text-align: left; color: var(--st-text-3); }
+ td.state { text-align: left; color: var(--st-text-2); }
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/DesignFilterBar.svelte b/web/src/components/pro/design/DesignFilterBar.svelte
index 4ff3a17b5..b9385db3f 100644
--- a/web/src/components/pro/design/DesignFilterBar.svelte
+++ b/web/src/components/pro/design/DesignFilterBar.svelte
@@ -199,10 +199,10 @@
.chip-count { font-family: monospace; font-size: 0.64rem; opacity: 0.8; }
.search { flex: 1; min-width: 140px; padding: 3px 7px; background: var(--st-surface);
border: 1px solid var(--st-hair-strong); border-radius: 3px; color: var(--st-text); font-size: 0.72rem; }
- .lbl { font-size: 0.7rem; color: var(--st-text-3); }
+ .lbl { font-size: 0.7rem; color: var(--st-text-2); }
.group-row { border-top: 1px dashed var(--st-surface-3); padding-top: 4px; }
.picker { max-width: 260px; padding: 2px 5px; background: var(--st-surface);
border: 1px solid var(--st-hair-strong); border-radius: 3px; color: var(--st-text); font-size: 0.7rem; }
- .refused { font-size: 0.66rem; color: var(--st-text-3); font-style: italic; }
+ .refused { font-size: 0.66rem; color: var(--st-text-2); font-style: italic; }
.group-note { display: flex; gap: 10px; flex-wrap: wrap; font-size: 0.66rem; color: var(--st-warn); }
diff --git a/web/src/components/pro/design/DesignOverview.svelte b/web/src/components/pro/design/DesignOverview.svelte
index f71a84c79..fae149aaf 100644
--- a/web/src/components/pro/design/DesignOverview.svelte
+++ b/web/src/components/pro/design/DesignOverview.svelte
@@ -290,13 +290,21 @@
.tone-muted { color: var(--st-text-3); }
/*
The same violet the 3-D view paints provisional steel with, and `OutcomeBadge`,
- `RebarStatusPanel` and `ProvisionalBanner` name the state with. Deliberately a literal
- while its neighbours are tokens: the authority is `three/rebar-scene.ts`, which feeds
- `0xa066d3` to a Three.js material and cannot read a custom property, and
- `run-summary-reported.test.ts` asserts that this chip agrees with it by value. A `var()`
- here would break that agreement without replacing it.
+ `RebarStatusPanel` and `ProvisionalBanner` name the state with.
+
+ MERGED. This chip arrives from `feat/pro-steel-family` as the literal `#a066d3`, with a
+ comment explaining that a `var()` would break the agreement with `three/rebar-scene.ts`,
+ which feeds `0xa066d3` to a Three.js material and cannot read a custom property. That was
+ true when it was written and is not true here: H1 created `--st-provisional` for exactly
+ this, and rule 3 of `shared-status-tokens.test.ts` holds it equal to what Three.js paints,
+ compared as a RESOLVED colour. The agreement is kept by a test rather than by two copies of
+ a hex, which is the stronger of the two arrangements — and it is the same conclusion H1
+ already reached one file over, in `OutcomeBadge`'s `.badge-provisional`.
+
+ So the token, not the literal: `concrete-design-raw-colours.test.ts` gives this file a
+ ceiling of zero and it stays at zero, with nothing about the colour left to chance.
*/
- .tone-prov .glyph, .tone-prov .n { color: #a066d3; }
+ .tone-prov .glyph, .tone-prov .n { color: var(--st-provisional); }
.open3d { display: flex; flex-direction: column; gap: 0.2rem; }
.open3d-btn {
diff --git a/web/src/components/pro/design/DesignTable.svelte b/web/src/components/pro/design/DesignTable.svelte
index e4f696b56..808c0e8ad 100644
--- a/web/src/components/pro/design/DesignTable.svelte
+++ b/web/src/components/pro/design/DesignTable.svelte
@@ -70,7 +70,9 @@
}
function barColor(row: DesignRow): string {
switch (row.status) {
- case 'fail': return 'var(--st-accent)';
+ // `--st-danger`, not the brand accent. Measured: the accent is 3.74–4.26 against the
+ // grounds this bar sits on and the danger token is 4.89–6.01.
+ case 'fail': return 'var(--st-danger)';
case 'warn': return 'var(--st-warn)';
case 'stale': return 'repeating-linear-gradient(45deg,var(--st-text-3) 0 3px,var(--st-text-3) 3px 6px)';
case 'unavailable': return 'var(--st-text-3)';
@@ -222,13 +224,15 @@
.expand-btn { background: none; border: none; color: inherit; font: inherit;
cursor: pointer; padding: 0; display: inline-flex; gap: 3px; align-items: center; }
.expand-btn:focus-visible { outline: 2px solid var(--st-value); outline-offset: 1px; }
+ /* The caret keeps `--st-text-3`: it is a glyph, not a sentence, so the bar is the 3:1 of
+ WCAG 2.1 §1.4.11 and it measures 3.74 on this ground. */
.caret { color: var(--st-text-3); width: 8px; display: inline-block; }
.ratio-cell { display: flex; align-items: center; gap: 5px; }
.ratio-value { font-family: monospace; min-width: 30px; }
.ratio-bar { flex: 1; height: 5px; background: var(--st-surface-3); border-radius: 3px; overflow: hidden; }
.ratio-fill { height: 100%; }
.detail-row td { background: var(--st-surface); padding: 8px 10px; }
- .empty { text-align: center; color: var(--st-text-3); padding: 18px; font-style: italic; }
+ .empty { text-align: center; color: var(--st-text-2); padding: 18px; font-style: italic; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; }
diff --git a/web/src/components/pro/design/DesignToolbar.svelte b/web/src/components/pro/design/DesignToolbar.svelte
index fddc32e9d..f6aef715b 100644
--- a/web/src/components/pro/design/DesignToolbar.svelte
+++ b/web/src/components/pro/design/DesignToolbar.svelte
@@ -414,11 +414,41 @@
color: var(--st-warn);
}
.detailing-blockers { margin: 0.3rem 0 0; font-size: 0.76rem; opacity: 0.85; }
+ /*
+ MERGED: the count rules are gone from here, and that is H1's own conclusion arriving by the
+ base's hand.
+
+ H1 moved the regulation read-out and the member counts OUT of this bar and into
+ `DesignOverview.svelte` — the comment at the top of the markup says so, and says nothing was
+ duplicated. What it did not do was delete the rules those elements left behind, so `.counts`,
+ `.count`, `.count-sep` and the nine `.c-*` sat here styling nothing.
+ `feat/pro-steel-family` deleted them, and measured against this file's own markup that is
+ exactly right: none of those fourteen selectors appears in it.
+
+ Nothing of H1's is lost with them. Its correction — `.c-fail` and `.c-sect` off the brand
+ `--st-accent` and onto `--st-danger`, because a result must not read as an action — lives on
+ the surface that renders the counts, where `.tone-bad` is already `--st-danger`.
+
+ `.c-prov` is the one worth naming. Its literal `#a066d3` was held equal to
+ `three/rebar-scene.ts` by a gate, and `run-summary-reported.test.ts` has since moved that
+ assertion onto the overview with the reason written into it: ".c-prov in the toolbar kept
+ this green while styling nothing". Deleting it here is what that note asks for, and the base
+ supplies the chip it asks for on the other side.
+
+ `.detailing-auto` is the exception and is NOT deleted: the markup still renders it at
+ `data-testid="detailing-auto-label"`. It is defined once now, beside `.cmd-detailing` above,
+ in the fuller form that carries the `:focus-within` outline — this second copy was a
+ duplicate that lost the focus ring.
+ */
.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/DetailingWorkflow.svelte b/web/src/components/pro/design/DetailingWorkflow.svelte
index b2918203d..81330ad86 100644
--- a/web/src/components/pro/design/DetailingWorkflow.svelte
+++ b/web/src/components/pro/design/DetailingWorkflow.svelte
@@ -380,55 +380,85 @@
.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); }
+ /* An opaque `--st-accent` fill with `--st-text` on it measures **3.69** — under AA, on a
+ 0.7rem chip. The tint-plus-rule form is 12.82 at worst and is what the rest of this
+ surface already uses for a failure. */
+ .superseded {
+ background: var(--st-danger-bg); color: var(--st-text);
+ border: 1px solid var(--st-danger);
+ }
.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; }
+ /*
+ `--st-text` on `--st-green` measures 4.06, and no text colour fixes it: dark ink on that
+ fill is 4.18. `tokens.css` says why — the plain hues are "for fills, rules and figures,
+ where area carries the meaning", not for a 0.7rem label sitting on top of one.
+
+ So `done` keeps the base well and signals with `--st-ok` at 4.88, full opacity against the
+ 0.6 of the steps still to come. `[aria-current='step']` already outlines the current one,
+ so the three states stay distinguishable without a filled chip.
+ */
+ .progress li.done { opacity: 1; color: var(--st-ok); }
.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); }
- .notice.error { background: var(--st-accent); color: var(--st-text); }
+ .notice.error {
+ background: var(--st-danger-bg); color: var(--st-text);
+ border-left: 3px solid var(--st-danger);
+ }
.ok { color: var(--st-ok); }
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; }
.doc-state { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; font-size: 12px; }
.badge { padding: 2px 8px; border-radius: 3px; font-weight: 600; font-size: 11px; }
- .badge-review_draft, .badge-superseded { background: var(--st-text); color: var(--st-accent); }
+ /* Inverted chip: a light ground, so the PLAIN hue rather than the `-text` variant — which
+ is what `tokens.css` says the two strengths are for. `--st-accent` on `--st-text` was
+ 3.85; `--st-red` is 6.04. */
+ .badge-review_draft, .badge-superseded { background: var(--st-text); color: var(--st-red); }
.badge-for_review { background: var(--st-text); color: var(--st-hair-strong); }
.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/components/pro/design/DocumentsSection.svelte b/web/src/components/pro/design/DocumentsSection.svelte
index b71eece26..d66af78c3 100644
--- a/web/src/components/pro/design/DocumentsSection.svelte
+++ b/web/src/components/pro/design/DocumentsSection.svelte
@@ -152,6 +152,33 @@
* The button was simply disabled. A control that governs a construction issue and explains
* itself with nothing but grey is the one place in this panel where silence is least excusable.
*/
+ /**
+ * What stands between this set and `Record review`, in the store's own words.
+ *
+ * `Record review` had no `disabled` and no explanation. Clicking it with an unaccepted
+ * provisional calculation called `detailingStore.review`, which refuses — and refused AFTER
+ * `retireDocument()` had already run, so the document the user had just built was superseded by
+ * a click that accomplished nothing. That ordering is fixed in the store as well; this gate is
+ * the half that stops the user reaching a refusal at all.
+ *
+ * These are the SAME three refusals `assembly.ts` raises and the store translates
+ * (`notConstructible` at line 481, `engineerRequired`, `provisionalOutstanding`), reusing the
+ * same locale keys. Not a new set of rules: the same sentences, said before the click instead
+ * of after it. Which is the principle the note under `issue-submit` already states.
+ */
+ const reviewBlockers = $derived.by(() => {
+ const out: string[] = [];
+ if (!selected) { out.push(t('detailing.doc.need.assembly')); return out; }
+ if (reviewRank(selected.state) < reviewRank('CONSTRUCTIBLE')) {
+ out.push(tp('detailing.review.notConstructible', { state: selected.state }));
+ }
+ if (!detailingAuthor.name.trim()) out.push(t('detailing.review.engineerRequired'));
+ if (provisional.length > 0 && !allAcknowledged) {
+ out.push(tp('detailing.review.provisionalOutstanding', { keys: provisional.join(', ') }));
+ }
+ return out;
+ });
+
const issueBlockers = $derived.by(() => {
const out: string[] = [];
if (!selected) { out.push(t('detailing.doc.need.assembly')); return out; }
@@ -182,6 +209,36 @@
{tp('detailing.doc.conflicts', { n: d.openConflicts.length })}
{/if}
+
+
+ {t('detailing.doc.contents.assemblies')}
+ {d.assemblies.length}
+ {t('detailing.doc.contents.certificates')}
+ {d.certificates.length}
+ {t('detailing.doc.contents.clauses')}
+ {d.refs.length}
+ {#if d.assumptions.length > 0}
+ {t('detailing.doc.contents.assumptions')}
+ {d.assumptions.length}
+ {/if}
+
+ {#if d.regulations.length > 0}
+
+
+ {d.regulations.map((r) => `${r.id} ${r.edition}`).join(' · ')}
+
+ {/if}
{:else}
{t('detailing.doc.notBuilt')}
{/if}
@@ -221,7 +278,10 @@
- {t('detailing.review')}
+
+ {t('detailing.review')}
{t('detailing.notLegalSignoff')}
{#if selected.review}
@@ -263,7 +323,11 @@
-
submitReview('REVIEWED')}>
+ 0}
+ onclick={() => submitReview('REVIEWED')}
+ >
{t('detailing.recordReview')}
+ {#if reviewBlockers.length > 0}
+ {reviewBlockers.join(' ')}
+ {/if}
{#if issueBlockers.length > 0}
{issueBlockers.join(' ')}
{/if}
@@ -305,7 +372,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;
@@ -346,6 +413,18 @@
.actions button[data-testid='issue-submit']:not(:disabled) { border-color: var(--st-interactive); font-weight: 600; }
.review { border-top: 1px solid var(--st-hair); padding-top: 0.5rem; }
+ .doc-contents {
+ display: grid; grid-template-columns: auto auto; gap: 0.05rem 0.5rem;
+ margin: 0.3rem 0 0; font-size: 0.7rem; justify-content: start;
+ }
+ .doc-contents dt { color: var(--st-text-2); }
+ .doc-contents dd {
+ margin: 0; font-family: var(--st-mono); font-variant-numeric: tabular-nums;
+ }
+ .doc-regs {
+ margin: 0.2rem 0 0; font-size: 0.66rem; color: var(--st-text-2);
+ font-family: var(--st-mono);
+ }
.disclaimer { margin: 0 0 0.3rem; font-size: 0.66rem; line-height: 1.35; color: var(--st-text-2); }
.reviewed { margin: 0 0 0.3rem; font-size: 0.68rem; color: var(--st-ok); }
diff --git a/web/src/components/pro/design/FloorFamiliesPanel.svelte b/web/src/components/pro/design/FloorFamiliesPanel.svelte
index a94694882..f20d007f2 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 @@
{#each [
- { key: 'slabs' as Family, label: t('detailing.floorRun.slabs'), n: slabCount },
- { key: 'walls' as Family, label: t('detailing.floorRun.walls'), n: wallCount },
- { key: 'foundations' as Family, label: t('detailing.floorRun.foundations'), n: footingCount },
+ { key: 'slabs' as Family, label: t('detailing.floorRun.slabs') },
+ { key: 'walls' as Family, label: t('detailing.floorRun.walls') },
+ { key: 'foundations' as Family, label: t('detailing.floorRun.foundations') },
] as f (f.key)}
+ {@const st = stateOf(f.key)}
(family = f.key)}>
- {f.label}{f.n}
+ {f.label}
+
+ {#if st.countsUnavailable}
+ —
+ {:else}
+ {st.classified}
+ {/if}
+
+ {GLYPH[st.kind]}
+ {t(`design.floor.state.${st.kind}`)}
+
{/each}
+
+
+
{#if family === 'slabs'}
{@const slabs = floorRun?.slabs ?? []}
{#if slabs.length === 0}
@@ -388,6 +447,7 @@
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/OutcomeBadge.svelte b/web/src/components/pro/design/OutcomeBadge.svelte
index b2130d533..f047c65ce 100644
--- a/web/src/components/pro/design/OutcomeBadge.svelte
+++ b/web/src/components/pro/design/OutcomeBadge.svelte
@@ -72,15 +72,31 @@
}
.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-danger); border-color: var(--st-danger); }
+ .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.
+
+ MERGED with `feat/pro-steel-family`, which reached the same conclusion independently and
+ wrote `--st-danger` here too. The branches AGREE on the semantics; what H1 adds on top is
+ the FILL, moved off `rgba(238, 34, 34, 0.16)` and onto `--st-danger-bg`, because
+ `concrete-design-raw-colours.test.ts` is a red gate on this surface and a literal here is a
+ second definition of a colour that already has a name. Nothing of the base's intent is
+ lost: its change IS this change, one token further along. */
+ .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 +105,23 @@
}
.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-warn); 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).
+
+ MERGED, and this is the one hunk where the two branches genuinely differ rather than
+ converge. `feat/pro-steel-family` moved the LABEL off `--st-accent` and onto `--st-warn`,
+ which fixes the brand-versus-status confusion and leaves warn-on-warn — the pairing the
+ measurement above rules out, and the one this chip has to stay distinguishable from. The
+ resolution keeps the base's intent (the accent is gone) and answers the gate the base did
+ not have to: the severity is still stated, on the border, at 3:1, and the words are
+ readable on every ground this chip sits on. */
+ .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/ProjectRegulationsPanel.svelte b/web/src/components/pro/design/ProjectRegulationsPanel.svelte
index 081095f96..dc3a77a1e 100644
--- a/web/src/components/pro/design/ProjectRegulationsPanel.svelte
+++ b/web/src/components/pro/design/ProjectRegulationsPanel.svelte
@@ -344,7 +344,7 @@
align-items: baseline;
}
.role-name { font-size: 0.72rem; font-weight: 600; color: var(--st-text); }
- .role-purpose { margin: 0; font-size: 0.66rem; line-height: 1.35; color: var(--st-text-3); }
+ .role-purpose { margin: 0; font-size: 0.66rem; line-height: 1.35; color: var(--st-text-2); }
.role-value { font-size: 0.68rem; color: var(--st-text-2); }
/* The badges wrap as a group of their own rather than fighting the selector for the row. */
@@ -381,7 +381,7 @@
}
dt { font-weight: 600; color: var(--st-text-2); }
dd { margin: 0; color: var(--st-text-2); }
- .note { font-size: 0.66rem; line-height: 1.35; color: var(--st-text-3); margin: 0.25rem 0 0; }
+ .note { font-size: 0.66rem; line-height: 1.35; color: var(--st-text-2); margin: 0.25rem 0 0; }
/* ── Notices ──────────────────────────────────────────────────── */
.notice {
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/components/pro/design/RebarEditorBeam.svelte b/web/src/components/pro/design/RebarEditorBeam.svelte
index 2cefc43df..bbe873937 100644
--- a/web/src/components/pro/design/RebarEditorBeam.svelte
+++ b/web/src/components/pro/design/RebarEditorBeam.svelte
@@ -142,8 +142,8 @@
.region-title { font-size: 0.7rem; font-weight: 600; color: var(--st-info); }
.total { font-size: 0.66rem; color: var(--st-text-2); font-family: monospace; margin-left: auto; }
.line { display: flex; align-items: center; gap: 4px; margin: 2px 0; }
- .line.empty { color: var(--st-text-3); font-size: 0.66rem; font-style: italic; }
- .idx { font-family: monospace; font-size: 0.64rem; color: var(--st-text-3); width: 16px; }
+ .line.empty { color: var(--st-text-2); font-size: 0.66rem; font-style: italic; }
+ .idx { font-family: monospace; font-size: 0.64rem; color: var(--st-text-2); width: 16px; }
.num { width: 44px; padding: 1px 4px; background: var(--st-surface); border: 1px solid var(--st-hair-strong);
border-radius: 3px; color: var(--st-text); font-size: 0.7rem; }
.num-sm { width: 34px; } .num-sp { width: 54px; }
@@ -151,8 +151,11 @@
border-radius: 3px; color: var(--st-text); font-size: 0.7rem; }
.sel-sm { width: 46px; }
.area { font-family: monospace; font-size: 0.64rem; color: var(--st-text-2); }
- .sub { font-size: 0.64rem; color: var(--st-text-3); }
- .warn { font-size: 0.64rem; color: var(--st-accent); font-weight: 700; }
+ .sub { font-size: 0.64rem; color: var(--st-text-2); }
+ /* `.warn` by class name, an error by content — it reads "does not fit". `--st-danger`
+ matches what it says rather than what it is called. The name is left alone: renaming it
+ touches the markup and the tests, and the colour was the defect. */
+ .warn { font-size: 0.64rem; color: var(--st-danger); font-weight: 700; }
.mini { padding: 0 5px; background: var(--st-surface-3); border: 1px solid var(--st-info);
border-radius: 3px; color: var(--st-text-2); font-size: 0.64rem; cursor: pointer; }
.mini:hover { background: var(--st-hair-strong); }
diff --git a/web/src/components/pro/design/RebarEditorColumn.svelte b/web/src/components/pro/design/RebarEditorColumn.svelte
index ac341baa6..7dba727d3 100644
--- a/web/src/components/pro/design/RebarEditorColumn.svelte
+++ b/web/src/components/pro/design/RebarEditorColumn.svelte
@@ -134,10 +134,10 @@
.sel { padding: 1px 3px; background: var(--st-surface); border: 1px solid var(--st-hair-strong);
border-radius: 3px; color: var(--st-text); font-size: 0.7rem; }
.sel-sm { width: 46px; }
- .sub { font-size: 0.64rem; color: var(--st-text-3); }
+ .sub { font-size: 0.64rem; color: var(--st-text-2); }
.total { font-family: monospace; font-size: 0.66rem; color: var(--st-text-2); }
- .total.bad { color: var(--st-accent); font-weight: 700; }
+ .total.bad { color: var(--st-danger); font-weight: 700; }
.issues { margin-top: 3px; }
- .issue { font-size: 0.64rem; color: var(--st-accent); }
+ .issue { font-size: 0.64rem; color: var(--st-danger); }
input:focus-visible, select:focus-visible { outline: 2px solid var(--st-value); outline-offset: 1px; }
diff --git a/web/src/components/pro/design/RebarLayersPanel.svelte b/web/src/components/pro/design/RebarLayersPanel.svelte
index 2b3373597..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)}
- {t('detailing.scene.section')}
+ {t('detailing.scene.section')}
{#if summary}
- {t('detailing.scene.tally.title')}
+ {t('detailing.scene.tally.title')}
{t('detailing.scene.tally.solids')} {summary.solidCount}
{t('detailing.scene.tally.reinforced')}
@@ -165,7 +167,8 @@
{#if pieces.length > 0}
- {t('detailing.scene.pieces.title')}
+
+ {t('detailing.scene.pieces.title')}
{#each pieces as [kind, n] (kind)}
@@ -195,7 +198,7 @@
Layers / Section / What the scene contains / Model status were four different weights and
sizes with no separators, which is a large part of why the viewer read as another program.
*/
- h4, h5 {
+ h3, h4 {
margin: 0 0 0.3rem;
font-size: 0.72rem;
font-weight: 600;
@@ -204,7 +207,7 @@
color: var(--st-text-2);
}
section { display: flex; flex-direction: column; }
- h4 { margin: 0 0 0.25rem; font-size: 0.8rem; }
+ h3 { margin: 0 0 0.25rem; font-size: 0.8rem; }
label {
display: flex; align-items: center; gap: 0.35rem;
font-size: 0.76rem; padding: 0.08rem 0; cursor: pointer;
@@ -220,13 +223,16 @@
border: 1px solid var(--st-hair-strong); border-radius: 4px;
padding: 0.2rem 0.5rem;
}
- .tally h5 { margin: 0.35rem 0 0.15rem; font-size: 0.75rem; }
+ .tally h4 { margin: 0.35rem 0 0.15rem; font-size: 0.75rem; }
.tally table { width: 100%; border-collapse: collapse; font-size: 0.72rem; }
.tally th {
text-align: left; font-weight: 400; color: var(--st-text-2);
padding: 0.08rem 0;
}
- .tally td { text-align: right; font-variant-numeric: tabular-nums; padding: 0.08rem 0; }
+ .tally td {
+ text-align: right; font-family: var(--st-mono); font-variant-numeric: tabular-nums;
+ padding: 0.08rem 0;
+ }
.tally .totals {
display: flex; flex-direction: column; gap: 0.05rem;
margin: 0 0 0.25rem; font-size: 0.72rem; color: var(--st-text-2);
diff --git a/web/src/components/pro/design/RebarScenePanel.svelte b/web/src/components/pro/design/RebarScenePanel.svelte
index 95539c8b3..0a547866d 100644
--- a/web/src/components/pro/design/RebarScenePanel.svelte
+++ b/web/src/components/pro/design/RebarScenePanel.svelte
@@ -205,16 +205,35 @@
.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; }
- .states strong { margin-left: auto; font-variant-numeric: tabular-nums; }
+ .states strong {
+ margin-left: auto; font-family: var(--st-mono); font-variant-numeric: tabular-nums;
+ }
.dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; }
.dot.failed { background: #e0444a; }
.dot.unsupported { background: #b06ad6; }
diff --git a/web/src/components/pro/design/RebarSchematics.svelte b/web/src/components/pro/design/RebarSchematics.svelte
index 30565b91b..ece68c932 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; }
- .dim { color: var(--st-text-3); }
+ .bad { color: var(--st-danger); font-weight: 700; }
+ .dim { color: var(--st-text-2); }
diff --git a/web/src/components/pro/design/RebarStatusPanel.svelte b/web/src/components/pro/design/RebarStatusPanel.svelte
index c44bae1d5..c15a1e8a6 100644
--- a/web/src/components/pro/design/RebarStatusPanel.svelte
+++ b/web/src/components/pro/design/RebarStatusPanel.svelte
@@ -90,7 +90,7 @@
- {t('detailing.scene.status.title')}
+ {t('detailing.scene.status.title')}
{t('detailing.scene.statusFilterHint')}
@@ -159,7 +159,7 @@
{/if}
- {t('detailing.scene.elements')} ({filtered.length})
+ {t('detailing.scene.elements')} ({filtered.length})
{#if filtered.length === 0}
{t('detailing.scene.noneOfState')}
{:else}
@@ -199,8 +199,8 @@
rail one row too short — which is exactly what happened. The rail scrolls; see the
`.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); }
+ h3, h4 { margin: 0; font-size: 0.82rem; }
+ .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,32 @@
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; }
+ /* `.n` is a count and takes the mono family; `.st` is a translated STATE WORD and does not.
+ They shared a rule, and `tabular-nums` never did anything for the word. */
+ .n { font-family: var(--st-mono); font-variant-numeric: tabular-nums; opacity: 0.85; }
+ .st { 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 +246,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 +266,33 @@
.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; }
- .cause-n { flex: none; font-variant-numeric: tabular-nums; font-weight: 600; }
+ /* `--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-family: var(--st-mono); 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/components/pro/design/RebarWorkspace.svelte b/web/src/components/pro/design/RebarWorkspace.svelte
index 6646dbd44..c348cb43f 100644
--- a/web/src/components/pro/design/RebarWorkspace.svelte
+++ b/web/src/components/pro/design/RebarWorkspace.svelte
@@ -37,6 +37,7 @@
} from '../../../lib/engine/detailing/element-status';
import RebarViewport3D from './RebarViewport3D.svelte';
import RebarStatusPanel from './RebarStatusPanel.svelte';
+ import RebarWorkspaceHeader from './RebarWorkspaceHeader.svelte';
import ProvisionalBanner from './ProvisionalBanner.svelte';
import TorsionBanner from './TorsionBanner.svelte';
import SelectionDetails from './SelectionDetails.svelte';
@@ -282,54 +283,17 @@
bind:this={dialogEl}
tabindex="-1"
>
-
- { railOpen = !railOpen; }}
- >☰
- {t('detailing.scene.workspace.title')}
- {#if built}
-
- {t(`detailing.doc.readiness.${built.scene.readiness}`)}
-
-
- {tp('detailing.doc.revision', { n: built.scene.revision })}
-
- {/if}
- {#if summary}
-
- {tp('detailing.scene.summary', {
- bars: summary.barCount,
- length: fmt(summary.totalLength),
- mass: fmt(summary.massKg, 1),
- })}
-
- {/if}
-
- {#if rebarWorkspace.canGoBack}
- rebarWorkspace.goBack()}
- >← {t('detailing.scene.back')}
- {/if}
- viewport?.fitView()}
- >
- {t('detailing.scene.reset')}
-
- rebarWorkspace.close()}
- >✕ {t('detailing.scene.workspace.close')}
-
+ rebarWorkspace.goBack()}
+ onFitView={() => viewport?.fitView()}
+ onClose={() => rebarWorkspace.close()}
+ />
@@ -427,6 +391,11 @@
background: var(--st-bg);
color: var(--st-text);
+ /* Mounted OUTSIDE `.app-container`, the one element declaring the app font, so it
+ inherited `-apple-system`. The mount point is deliberate and stays; see
+ `docs/handoffs/h1a-concrete-flow-audit.md` §4. */
+ font-family: var(--st-sans);
+
/*
── Why the workspace looked like a different application ───────
@@ -456,6 +425,12 @@
--panel: var(--st-surface);
}
+ /* Controls do not inherit a font: 12 buttons and 13 inputs in here were Arial. Scoped to
+ `.workspace` — the rest of the application has the same defect at a far larger scale and is
+ not H1's file to fix. `font-family`, not `font`, which would reset deliberate sizes. */
+ .workspace :global(button), .workspace :global(input),
+ .workspace :global(select), .workspace :global(textarea) { font-family: inherit; }
+
/* The container is `tabindex="-1"` purely as a landing pad for focus on open, so it can
never be tabbed TO and a ring around the whole window would only say "something is
broken". Every control inside it keeps its own. */
@@ -468,34 +443,6 @@
tokens are the same ones the PRO panel and the ribbon use, so the overlay is a VIEW of this
app rather than another one. Nothing about the scene, the batching or the states changed.
*/
- .topbar {
- display: flex; align-items: center; gap: 0.6rem;
- padding: 0.45rem 0.75rem;
- border-bottom: 1px solid var(--st-hair);
- background: var(--st-surface);
- flex: 0 0 auto;
- flex-wrap: wrap;
- }
- /* The same heading weight the panel headers use, so the two read as one hierarchy. */
- .topbar h2 { margin: 0; font-size: 0.9rem; font-weight: 600; color: var(--st-text); }
- .spacer { flex: 1 1 auto; }
- /* The same pill the design surface uses for a state, not a lookalike. */
- .badge {
- font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 3px; font-weight: 600;
- background: var(--st-surface-3); color: var(--st-text);
- }
- .rev, .sum { font-size: 0.74rem; color: var(--st-text-2); }
- .topbar button {
- font-size: 0.76rem; padding: 0.25rem 0.6rem; cursor: pointer;
- background: var(--st-surface-3); color: var(--st-text);
- border: 1px solid var(--st-hair-strong); border-radius: 4px;
- }
- .topbar button:hover { background: var(--st-hair-strong); }
- .topbar button:focus-visible { outline: 2px solid var(--st-value); outline-offset: 1px; }
- /* Leaving is the one action here that changes where you are, so it carries the accent border. */
- .topbar button.close { border-color: var(--st-interactive); }
- .rail-toggle { display: none; }
-
.body { display: flex; flex: 1 1 auto; min-height: 0; }
.rail {
width: 17rem; flex: 0 0 auto; overflow-y: auto;
@@ -569,9 +516,6 @@
* reason the workspace exists. The rail is one tap away and starts closed.
*/
@media (max-width: 860px) {
- .rail-toggle { display: inline-block; }
- .topbar h2 { font-size: 0.85rem; }
- .rev, .sum { display: none; }
.body { position: relative; }
.rail {
position: absolute; inset: 0 auto 0 0; z-index: 2;
diff --git a/web/src/components/pro/design/RebarWorkspaceHeader.svelte b/web/src/components/pro/design/RebarWorkspaceHeader.svelte
new file mode 100644
index 000000000..9af92c62e
--- /dev/null
+++ b/web/src/components/pro/design/RebarWorkspaceHeader.svelte
@@ -0,0 +1,138 @@
+
+
+
+ { railOpen = !railOpen; }}
+ >☰
+ {t('detailing.scene.workspace.title')}
+ {#if readiness}
+
+ {t(`detailing.doc.readiness.${readiness}`)}
+
+ {tp('detailing.doc.revision', { n: revision ?? 0 })}
+ {/if}
+ {#if summary}
+
+ {tp('detailing.scene.summary', {
+ bars: summary.barCount,
+ length: fmt(summary.totalLength),
+ mass: fmt(summary.massKg, 1),
+ })}
+
+ {/if}
+
+
+ {#if canGoBack}
+
+ ← {t('detailing.scene.back')}
+
+ {/if}
+
+ {t('detailing.scene.reset')}
+
+
+
+ ✕ {t('detailing.scene.workspace.close')}
+
+
+
+
diff --git a/web/src/components/pro/design/SelectionDetails.svelte b/web/src/components/pro/design/SelectionDetails.svelte
index ca8501060..a4381b0bf 100644
--- a/web/src/components/pro/design/SelectionDetails.svelte
+++ b/web/src/components/pro/design/SelectionDetails.svelte
@@ -45,6 +45,9 @@
}: Props = $props();
const fmt = (n: number, d = 2): string => n.toFixed(d);
+
+ /** True while an isolation is active. Drives one button rather than two. */
+ const isolating = $derived(rebarWorkspace.isolated.length > 0);
{#if bar}
@@ -101,18 +104,29 @@
{t('detailing.scene.torsionMember')}
{/if}
+
- {#if rebarWorkspace.isolated.length > 0}
- rebarWorkspace.clearIsolation()}>
- {t('detailing.scene.clearIsolation')}
-
- {:else}
- rebarWorkspace.isolate(rebarWorkspace.selection?.elementIds ?? [])}>
- {t('detailing.scene.isolate')}
-
- {/if}
+ (isolating
+ ? rebarWorkspace.clearIsolation()
+ : rebarWorkspace.isolate(rebarWorkspace.selection?.elementIds ?? []))}
+ >
+ {isolating ? t('detailing.scene.clearIsolation') : t('detailing.scene.isolate')}
+
{/if}
@@ -122,12 +136,17 @@
margin: 0; font-size: 0.74rem;
}
dt { color: var(--text-muted, #8b93a3); }
- dd { margin: 0; }
+ /* Its sibling `ConflictInspector` marked its `dd` as a figure column and this one never did,
+ though both report measured values in the same rail — a clearance in one, a diameter and
+ a length in the other. Same treatment now. */
+ dd { margin: 0; font-family: var(--st-mono); font-variant-numeric: tabular-nums; }
.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 +157,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/components/pro/design/VerificationDetail.svelte b/web/src/components/pro/design/VerificationDetail.svelte
index 2fd649556..c747021aa 100644
--- a/web/src/components/pro/design/VerificationDetail.svelte
+++ b/web/src/components/pro/design/VerificationDetail.svelte
@@ -208,17 +208,23 @@
.axes-line { display: flex; gap: 8px; flex-wrap: wrap; font-size: 0.68rem; color: var(--st-text-2); }
.mono { font-family: monospace; }
.small { font-size: 0.64rem; }
- .muted { color: var(--st-text-3); }
+ .muted { color: var(--st-text-2); }
.cert { display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
padding: 4px 7px; border-radius: 4px; font-size: 0.68rem; }
.cert-ok { background: rgba(34,204,102,0.10); border: 1px solid var(--st-ok); color: var(--st-text); }
- .cert-none { background: rgba(180,120,220,0.10); border: 1px solid var(--st-text-3); color: var(--st-text); }
+ /* A violet fill on a badge whose border and label are both neutral, for a state that means
+ "there is no certificate". `--st-provisional-bg` would be the near match and the wrong
+ answer: an absence is not a provisional result, and the violet was quietly claiming it
+ was. `--st-surface-3` is what its own border and text already said. */
+ .cert-none { background: var(--st-surface-3); border: 1px solid var(--st-text-3); color: var(--st-text); }
.reason { font-size: 0.67rem; color: var(--st-text-2); padding-left: 4px; }
.limiting { display: flex; gap: 4px; flex-wrap: wrap; }
.lim-chip { padding: 0 5px; background: var(--st-surface-3); border: 1px solid var(--st-text-3);
border-radius: 3px; font-size: 0.62rem; color: var(--st-text); font-family: monospace; }
.prov-note { display: flex; gap: 6px; align-items: center; font-size: 0.67rem; }
- .advice { border: 1px solid var(--st-warn); background: rgba(255,102,0,0.08);
+ /* The fourth site of the orange that was never `--st-warn` — beside a border that already
+ was. One hue now. */
+ .advice { border: 1px solid var(--st-warn); background: var(--st-warn-bg);
border-radius: 4px; padding: 5px 7px; display: flex; flex-direction: column; gap: 3px; }
.advice-head { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 0.7rem; color: var(--st-text); }
.prelim { font-size: 0.62rem; color: var(--st-warn); font-style: italic; }
@@ -235,7 +241,7 @@
.chk-fail td { color: var(--st-danger); }
.chk-warn td { color: var(--st-warn); }
.desc-row td { border-bottom: 1px solid var(--st-surface-3); }
- .desc { font-size: 0.62rem; color: var(--st-text-3); padding-left: 12px !important; }
+ .desc { font-size: 0.62rem; color: var(--st-text-2); padding-left: 12px !important; }
.none-note { display: flex; gap: 6px; align-items: center; font-size: 0.68rem; }
.fold { border: 1px solid var(--st-surface-3); border-radius: 4px; padding: 3px 6px; }
.fold summary { cursor: pointer; font-size: 0.68rem; color: var(--st-info); }
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..c25051e9e
--- /dev/null
+++ b/web/src/lib/__tests__/concrete-design-raw-colours.test.ts
@@ -0,0 +1,179 @@
+/**
+ * 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,
+ // 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,
+ // 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,
+ // Both are `rgba(0,0,0,0.6)`: a modal scrim and a drop shadow. `tokens.css` has no
+ // `--st-scrim` and no shadow token, and three other dialogs write the same value — so this
+ // is a shared gap, reported rather than papered over with a near-match.
+ 'SectionAdviceDialog.svelte': 2,
+ // 5 left, all of them required fallbacks: four `--text-muted` and one `--st-border`.
+ 'SelectionDetails.svelte': 5,
+ // 1 left: `rgba(34,204,102,0.10)` on `.cert-ok`. There is no `--st-ok-bg`, and inventing a
+ // fourth status surface is not this commit's call — see the proposal document.
+ 'VerificationDetail.svelte': 1,
+ // ── shared PRO surface: coordinate before lowering ──
+ // 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,
+// then the shared contract: −6 OutcomeBadge, −4 ProvisionalBanner, −2 DesignToolbar,
+// then the rest of bucket 1: −3 FootingCadHandoffPanel, −3 FootingMatPanel,
+// −2 DesignFamilyPanel, −2 VerificationDetail, −1 FloorFamiliesPanel.
+const TOTAL_CEILING = 64;
+
+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([]);
+ });
+});
+
+/**
+ * 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', 'TorsionBanner.svelte',
+ 'ProvisionalBanner.svelte', 'FootingCadHandoffPanel.svelte', 'FootingMatPanel.svelte',
+ 'DesignFamilyPanel.svelte', 'FloorFamiliesPanel.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');
+
+ 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\)/);
+ });
+});
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..fdd2185af
--- /dev/null
+++ b/web/src/lib/__tests__/concrete-status-tokens.test.ts
@@ -0,0 +1,538 @@
+/**
+ * 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 {
+ 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];
+}
+
+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 still have no token to go to', () => {
+ /**
+ * 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.
+ *
+ * `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|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);
+ }
+ });
+
+ 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/);
+ });
+});
+
+/**
+ * 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');
+ });
+});
+
+/**
+ * The rest of bucket 1, once the shared contract existed.
+ *
+ * Five of these six were waiting on a token that did not exist: `--st-danger-bg` for the CAD
+ * failure band and `--st-warn-bg` for the advice band. Two things they were NOT waiting on are
+ * the interesting part, so both are asserted as deliberate rather than left to look unfinished.
+ */
+describe('bucket 1 after the contract', () => {
+ const css = (f: string) => read(f).replace(/\/\*[\s\S]*?\*\//g, '');
+
+ it('the CAD failure band is the danger surface, not another hand-mixed red', () => {
+ const c = css('FootingCadHandoffPanel.svelte');
+ // `#5c1a1a`/`#ffe4e4` — the third and fourth appearance of the pair `FootingMatPhysicalPanel`
+ // carried, in the same panel family.
+ for (const gone of ['#5c1a1a', '#ffe4e4', '#3a3a3a']) {
+ expect(c, gone).not.toContain(gone);
+ }
+ expect(c).toMatch(/\.failed li \{[^}]*var\(--st-danger-bg\)/);
+ expect(c).toMatch(/\.failed li \{[^}]*border-left: 3px solid var\(--st-danger\)/);
+ expect(c, 'and the words at full contrast').toMatch(/\.failed \{[^}]*var\(--st-text\)/);
+ });
+
+ it('the hair tokens stop being written out by hand', () => {
+ // `rgba(143,163,179, α)` IS `--st-hair` at 0.22 and `--st-hair-strong` at 0.38. Three files
+ // wrote it at 0.2, 0.25 and 0.3 — a token approximated three ways.
+ for (const f of ['FootingMatPanel.svelte', 'FloorFamiliesPanel.svelte']) {
+ expect(css(f), `${f} still approximates the hair token`)
+ .not.toMatch(/rgba\(143,\s*163,\s*179/);
+ }
+ expect(css('FootingMatPanel.svelte')).toMatch(/border-top: 1px solid var\(--st-hair-strong\)/);
+ expect(css('FloorFamiliesPanel.svelte')).toMatch(/border-bottom: 1px solid var\(--st-border\)/);
+ });
+
+ it('the DESIGNED badge stays neutral, like its sibling MODELED badge', () => {
+ // Designed is not verified, and no status hue may suggest it is. Same rule the mat panel's
+ // header states in as many words.
+ const rule = css('FootingMatPanel.svelte').match(/\.badge\.status-DESIGNED \{([^}]*)\}/);
+ 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], `DESIGNED must not reach for ${t}`).not.toContain(t);
+ }
+ });
+
+ it('the design table\'s red was a coincidence, not the scene contract', () => {
+ /*
+ * `#e0444a` in `DesignFamilyPanel` is also `conflicted: 0xe0444a`. The value matched; the
+ * meaning did not. This is the design RESULTS table and the 3-D viewer paints nothing in it,
+ * so the token applies here while the identical literal stays frozen in the three panels the
+ * scene really does mirror.
+ */
+ const c = css('DesignFamilyPanel.svelte');
+ expect(c).not.toContain('#e0444a');
+ expect(c).toMatch(/tr\.failed td\.state \{ color: var\(--st-danger\); \}/);
+ // And the frozen ones are still frozen, which is what makes the distinction real.
+ expect(read('RebarStatusPanel.svelte')).toContain('#e0444a');
+ expect(read('ConflictInspector.svelte')).toContain('#e0444a');
+ });
+
+ it('a violet that meant "no certificate" stops claiming to mean provisional', () => {
+ // `rgba(180,120,220,.10)` sat in provisional's hue family on a badge whose border and label
+ // were both neutral. `--st-provisional-bg` was the near match and the wrong answer.
+ const c = css('VerificationDetail.svelte');
+ expect(c).not.toContain('rgba(180,120,220');
+ expect(c).toMatch(/\.cert-none \{[^}]*background: var\(--st-surface-3\)/);
+ expect(c, 'and it did NOT take the provisional surface')
+ .not.toMatch(/\.cert-none \{[^}]*--st-provisional/);
+ // The advice band did take the token it was always approximating.
+ expect(c).not.toContain('rgba(255,102,0');
+ expect(c).toMatch(/\.advice \{[^}]*var\(--st-warn-bg\)/);
+ });
+
+ it('and the two that stayed, stayed for a stated reason', () => {
+ /**
+ * Neither is debt anybody forgot.
+ *
+ * `SectionAdviceDialog` writes `rgba(0,0,0,0.6)` twice — a modal scrim and a drop shadow —
+ * and `tokens.css` has no scrim token and no shadow token. Three other dialogs write the
+ * same value, so it is a shared gap and not this file's to invent.
+ *
+ * `VerificationDetail`'s `.cert-ok` is `rgba(34,204,102,0.10)`, and there is no
+ * `--st-ok-bg`. The contract deliberately shipped two status surfaces, not four.
+ */
+ expect(css('SectionAdviceDialog.svelte')).toContain('rgba(0,0,0,0.6)');
+ expect(TOKENS, 'no scrim token exists to move to').not.toMatch(/--st-(scrim|overlay|shadow):/);
+
+ expect(css('VerificationDetail.svelte')).toContain('rgba(34,204,102,0.10)');
+ expect(TOKENS, 'and no ok surface either').not.toMatch(/--st-ok-bg:/);
+ });
+});
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..24a55e40b
--- /dev/null
+++ b/web/src/lib/__tests__/shared-status-tokens.test.ts
@@ -0,0 +1,459 @@
+/**
+ * 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')));
+ });
+});
+
+/**
+ * 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) {
+ 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 (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)':
+ 'open — `.badge-outcome-SEARCH_EXHAUSTED`. A violet in provisional\'s hue family for a '
+ + 'state that is NOT provisional, on a badge whose border and label are already neutral. '
+ + '`--st-provisional-bg` would be the near match and the wrong answer; there is no token '
+ + 'for what it means. The last one standing.',
+
+ // ── 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);
+ });
+});
+
+/**
+ * `--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.',
+ 'DesignToolbar.svelte|.progress-fill { height: 100%; background: var(--st-accent); transition: width 0.15s linear; }':
+ 'action — the fill of the run\'s own progress bar, and the accent is what this '
+ + 'application paints an action in progress with. Not a status: it says nothing about any '
+ + 'member, and the run\'s state is the words beside it. Nor a contrast site — it is a '
+ + 'decorative fill inside a track, with the count written next to it in --st-text-2, so '
+ + 'nothing here is read off the colour. Arrived from feat/pro-steel-family, which restored '
+ + 'a fill that the merge base had left at `background: none` — an invisible progress bar.',
+ "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);
+ }
+ });
+});
diff --git a/web/src/lib/data/__tests__/cold-formed-lip-convention.test.ts b/web/src/lib/data/__tests__/cold-formed-lip-convention.test.ts
new file mode 100644
index 000000000..1173bf2ff
--- /dev/null
+++ b/web/src/lib/data/__tests__/cold-formed-lip-convention.test.ts
@@ -0,0 +1,171 @@
+/**
+ * The calculation and the drawing describe the same channel.
+ *
+ * ── What this replaces ─────────────────────────────────────────────
+ *
+ * The app measured a lip two ways. `computeSectionProperties`'s `C-custom` used the flange's
+ * MID-LINE (`(h - tf)/2 - c/2`); `createCShape` and `crossSectionPath` both walk from the OUTER
+ * face. With the same `c` the calculation counted `2t²` more material than either drawing — 452 mm²
+ * against 444 on a `C 100x50x15x2`, about 1.8 %.
+ *
+ * The outer-face convention is the one the drawings already used and the one a cold-formed
+ * designation means: in `C 100x50x15x2` the `15` is the total lip depth measured from outside.
+ * Decided in `docs/handoffs/m2-lip-convention-proposal.md`.
+ *
+ * ── Why the polygon and not a table of expected numbers ────────────
+ *
+ * A table of four sections would pin the four sections. This integrates the ACTUAL polygon
+ * `createCShape` walks — Green's theorem over its vertices — and compares A, Iy and Iz against
+ * what `computeSectionProperties` returns for the same parameters. The property under test is
+ * "these two describe the same object", and only a comparison against the outline can state it.
+ *
+ * It is also the assertion that cannot be satisfied by accident: convention A misses it by exactly
+ * `2t²` in area, and there is no third convention that matches.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { computeSectionProperties } from '../section-shapes';
+import { createCShape } from '../../three/section-profiles';
+
+/** Area, and second moments about the CENTROID, of a closed polygon. Green's theorem. */
+function polygonMoments(pts: Array<{ x: number; y: number }>) {
+ let a2 = 0, cx = 0, cy = 0;
+ for (let i = 0; i < pts.length; i++) {
+ const p = pts[i], q = pts[(i + 1) % pts.length];
+ const cross = p.x * q.y - q.x * p.y;
+ a2 += cross;
+ cx += (p.x + q.x) * cross;
+ cy += (p.y + q.y) * cross;
+ }
+ const area = a2 / 2;
+ cx /= 3 * a2;
+ cy /= 3 * a2;
+
+ // Second moments about the origin, then shifted to the centroid.
+ let ixx = 0, iyy = 0;
+ for (let i = 0; i < pts.length; i++) {
+ const p = pts[i], q = pts[(i + 1) % pts.length];
+ const cross = p.x * q.y - q.x * p.y;
+ ixx += (p.y * p.y + p.y * q.y + q.y * q.y) * cross;
+ iyy += (p.x * p.x + p.x * q.x + q.x * q.x) * cross;
+ }
+ ixx = ixx / 12 - area * cy * cy;
+ iyy = iyy / 12 - area * cx * cx;
+
+ return { area: Math.abs(area), ixx: Math.abs(ixx), iyy: Math.abs(iyy) };
+}
+
+/** The outline `createCShape` walks, as points. `THREE.Shape` records them for us. */
+function drawnOutline(h: number, b: number, tw: number, tf: number, c: number, tl: number) {
+ const shape = createCShape(h, b, tw, tf, c, tl);
+ return shape.getPoints(1).map((p) => ({ x: p.x, y: p.y }));
+}
+
+/**
+ * Real cold-formed sizes, in metres — the unit `computeSectionProperties` works in.
+ *
+ * The four from the proposal's table, so the numbers here can be read against it.
+ */
+const SECTIONS = [
+ { name: 'C 100x50x15x2.0', h: 0.100, b: 0.050, t: 0.0020, c: 0.015 },
+ { name: 'C 150x60x20x2.5', h: 0.150, b: 0.060, t: 0.0025, c: 0.020 },
+ { name: 'C 200x75x20x3.0', h: 0.200, b: 0.075, t: 0.0030, c: 0.020 },
+ { name: 'C 80x40x12x1.5', h: 0.080, b: 0.040, t: 0.0015, c: 0.012 },
+] as const;
+
+describe('the lip convention: calculation and drawing agree', () => {
+ for (const s of SECTIONS) {
+ it(`${s.name} — A, Iy and Iz match the drawn outline`, () => {
+ const props = computeSectionProperties('C-custom', {
+ h: s.h, b: s.b, tw: s.t, tf: s.t, c: s.c, tl: s.t,
+ });
+ expect(props, 'the section is valid').not.toBeNull();
+
+ const poly = polygonMoments(drawnOutline(s.h, s.b, s.t, s.t, s.c, s.t));
+
+ /*
+ * `createCShape` draws in the (z, y) plane with the web at x = 0, so its `iyy` is the
+ * second moment about the VERTICAL axis — the section's Iz — and its `ixx` is Iy.
+ *
+ * Relative tolerance, not absolute: these span 267 mm² to 1134 mm² and 10⁻⁷ m⁴ to 10⁻⁵ m⁴,
+ * and one epsilon cannot be right for both.
+ */
+ expect(props!.a).toBeCloseTo(poly.area, 12);
+ expect(Math.abs(props!.iy / poly.ixx - 1), `${s.name} Iy`).toBeLessThan(1e-9);
+ expect(Math.abs(props!.iz / poly.iyy - 1), `${s.name} Iz`).toBeLessThan(1e-9);
+ });
+ }
+
+ it('and the old mid-line convention would miss the area by exactly 2t²', () => {
+ /*
+ * The discrepancy, restated as arithmetic so the fix cannot be undone quietly. Convention A
+ * added a lip of length `c`; B adds `c - tf`. With `tl = tf = t` the difference is
+ * `2 · t · t`, which is the 8 / 12.5 / 18 / 4.5 mm² the proposal measured.
+ */
+ for (const s of SECTIONS) {
+ const props = computeSectionProperties('C-custom', {
+ h: s.h, b: s.b, tw: s.t, tf: s.t, c: s.c, tl: s.t,
+ })!;
+ const oldArea = props.a + 2 * s.t * s.t;
+ const poly = polygonMoments(drawnOutline(s.h, s.b, s.t, s.t, s.c, s.t));
+ expect(oldArea - poly.area).toBeCloseTo(2 * s.t * s.t, 12);
+ }
+ });
+});
+
+describe('a lip no deeper than the flange is a plain channel, in both halves', () => {
+ /**
+ * The regime that was worse than a `t/2` shift.
+ *
+ * `createCShape` renders an UNLIPPED channel when `lip <= tf`, and the calculation used to add
+ * `2·c·tl` of lip anyway. So the app computed a section with a lip and drew one without —
+ * material that existed in the numbers and not in the outline.
+ *
+ * Under the outer-face convention the useful lip is `c - tf`, which is ≤ 0 exactly when the
+ * drawing refuses to draw one. No new guard; the two halves agree about WHETHER there is a lip,
+ * not only about where it is.
+ */
+ const base = { h: 0.100, b: 0.050, tw: 0.002, tf: 0.002, tl: 0.002 };
+
+ it('c === tf gives the same properties as the plain channel it draws', () => {
+ const lipped = computeSectionProperties('C-custom', { ...base, c: base.tf })!;
+ const poly = polygonMoments(drawnOutline(base.h, base.b, base.tw, base.tf, base.tf, base.tl));
+ expect(lipped.a).toBeCloseTo(poly.area, 12);
+ expect(Math.abs(lipped.iy / poly.ixx - 1)).toBeLessThan(1e-9);
+ expect(Math.abs(lipped.iz / poly.iyy - 1)).toBeLessThan(1e-9);
+ });
+
+ it('c < tf too, rather than being rejected', () => {
+ const props = computeSectionProperties('C-custom', { ...base, c: 0.001 });
+ expect(props, 'a shallow lip computes rather than refusing').not.toBeNull();
+ const poly = polygonMoments(drawnOutline(base.h, base.b, base.tw, base.tf, 0.001, base.tl));
+ expect(props!.a).toBeCloseTo(poly.area, 12);
+ });
+
+ it('and it equals a U of the same plate', () => {
+ // The strongest statement of the same fact: no lip means it IS the channel.
+ const asC = computeSectionProperties('C-custom', { ...base, c: base.tf })!;
+ const asU = computeSectionProperties('U-custom', {
+ h: base.h, b: base.b, tw: base.tw, tf: base.tf,
+ })!;
+ expect(asC.a).toBeCloseTo(asU.a, 12);
+ expect(asC.iy).toBeCloseTo(asU.iy, 12);
+ });
+});
+
+describe('the validity bound follows the convention', () => {
+ it('lips collide at c > h/2, not at c + tf > h/2', () => {
+ /*
+ * A deliberate loosening, and the second sub-decision the patch exposes. Under the old bound a
+ * lip of exactly `h/2 - tf` was the deepest allowed; under the outer face the lips meet when
+ * their outer-face depths sum to `h`, so the bound is `c > h/2`.
+ */
+ const base = { h: 0.100, b: 0.050, tw: 0.002, tf: 0.002, tl: 0.002 };
+ expect(computeSectionProperties('C-custom', { ...base, c: 0.049 }),
+ 'just inside the new bound, rejected by the old one').not.toBeNull();
+ expect(computeSectionProperties('C-custom', { ...base, c: 0.050 }),
+ 'exactly h/2 is still allowed').not.toBeNull();
+ expect(computeSectionProperties('C-custom', { ...base, c: 0.0501 }),
+ 'past h/2 the lips would meet').toBeNull();
+ });
+});
diff --git a/web/src/lib/data/section-shapes.ts b/web/src/lib/data/section-shapes.ts
index f6e3d56fc..c126f7831 100644
--- a/web/src/lib/data/section-shapes.ts
+++ b/web/src/lib/data/section-shapes.ts
@@ -332,24 +332,55 @@ export function computeSectionProperties(
case 'C-custom': {
const { h, b, tw, tf, c, tl } = params;
if (!h || !b || !tw || !tf || !c || !tl || h <= 0 || b <= 0 || tw <= 0 || tf <= 0 || c <= 0 || tl <= 0) return null;
- if (2 * tf >= h || tw >= b || c + tf > h / 2) return null;
+ /*
+ ── The OUTER-FACE convention ────────────────────────────────────
+
+ `c` is the lip depth measured from the flange's OUTER face. That is how a cold-formed
+ designation reads — in `C 100x50x15x2` the `15` is the total lip depth measured from
+ outside — and it is what BOTH drawing implementations already do: `createCShape` walks
+ from `-halfH`, and `crossSectionPath`'s `'C'` from `-hh + lip`.
+
+ This calculation was the odd one out. It measured from the flange's MID-LINE
+ (`(h - tf)/2 - c/2`), so with the same `c` it counted `2t²` more material than either
+ drawing: 452 mm² against 444 on a `C 100x50x15x2`, about 1.8 %. Two parts of the
+ application described different objects, and the one that disagreed with the designation
+ was this one — a user typing 15 got a lip 15 + t/2 deep.
+
+ So the lip that ADDS material is only the part beyond the flange, `c - tf`.
+ `cold-formed-lip-convention.test.ts` checks A, Iy and Iz against the moments of the
+ polygon `createCShape` actually walks, on a grid, and they agree to machine precision.
+
+ Two consequences taken on purpose, not by omission:
+
+ · The validity bound loosens. Lips collide when `c > h/2`, not `c + tf > h/2`.
+ · `c <= tf` stops being an error and becomes a PLAIN CHANNEL. That is what the drawing
+ already did — `createCShape` renders an unlipped channel for `lip <= tf` while this
+ function happily added `2·c·tl` of lip — so for `c <= tf` the app used to compute a
+ section with a lip and draw one without. `Math.max(0, …)` closes it by construction,
+ with no new guard: the useful lip is ≤ 0 exactly when the drawing refuses to draw one.
+
+ Convention decided in `docs/handoffs/m2-lip-convention-proposal.md` (M1). H1 owns this
+ file because it also holds the concrete templates.
+ */
+ if (2 * tf >= h || tw >= b || c > h / 2) return null;
const hw = h - 2 * tf;
- const a = tw * hw + 2 * b * tf + 2 * c * tl;
+ const cl = Math.max(0, c - tf); // lip beyond the flange; 0 means a plain channel
+ const a = tw * hw + 2 * b * tf + 2 * cl * tl;
// Iy (about Y horizontal): h-dominated, symmetric
const iyWeb = (tw * hw ** 3) / 12;
const iyFlanges = 2 * ((b * tf ** 3) / 12 + b * tf * ((h - tf) / 2) ** 2);
- const yLipCenter = (h - tf) / 2 - c / 2;
- const iyLips = 2 * ((tl * c ** 3) / 12 + tl * c * yLipCenter ** 2);
+ const yLipCenter = (h - c - tf) / 2;
+ const iyLips = 2 * ((tl * cl ** 3) / 12 + tl * cl * yLipCenter ** 2);
// Iz (about Z vertical): z-centroid not centered
- const zBar = (tw * hw * (tw / 2) + 2 * b * tf * (b / 2) + 2 * c * tl * (b - tl / 2)) / a;
+ const zBar = (tw * hw * (tw / 2) + 2 * b * tf * (b / 2) + 2 * cl * tl * (b - tl / 2)) / a;
const izWeb = (hw * tw ** 3) / 12 + hw * tw * (tw / 2 - zBar) ** 2;
const izFlanges = 2 * ((tf * b ** 3) / 12 + b * tf * (b / 2 - zBar) ** 2);
- const izLips = 2 * ((c * tl ** 3) / 12 + c * tl * (b - tl / 2 - zBar) ** 2);
+ const izLips = 2 * ((cl * tl ** 3) / 12 + cl * tl * (b - tl / 2 - zBar) ** 2);
return {
a,
iy: iyWeb + iyFlanges + iyLips,
iz: izWeb + izFlanges + izLips,
- j: (1 / 3) * (hw * tw ** 3 + 2 * b * tf ** 3 + 2 * c * tl ** 3),
+ j: (1 / 3) * (hw * tw ** 3 + 2 * b * tf ** 3 + 2 * cl * tl ** 3),
b, h, tw, tf,
t: c,
tl,
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/__tests__/run-summary-reported.test.ts b/web/src/lib/engine/design/__tests__/run-summary-reported.test.ts
index 5c69f4df8..1dd229135 100644
--- a/web/src/lib/engine/design/__tests__/run-summary-reported.test.ts
+++ b/web/src/lib/engine/design/__tests__/run-summary-reported.test.ts
@@ -124,7 +124,20 @@ describe('every design-run bucket reaches the user', () => {
// `.c-prov` in the toolbar kept this green while styling nothing).
expect(overviewSrc).toContain('data-testid="summary-count-provisional"');
expect(overviewSrc).toContain("t('design.counts.provisional')");
- expect(overviewSrc, 'the provisional chip carries the proposal violet').toContain('#a066d3');
+ /*
+ * The TOKEN, not the hex.
+ *
+ * This asserted `#a066d3` literally, because when it was written there was no name for that
+ * colour and the only way to hold the chip equal to `three/rebar-scene.ts` was to compare two
+ * copies of a hex. `--st-provisional` exists now, and rule 3 of `shared-status-tokens.test.ts`
+ * holds it equal to what Three.js paints as a RESOLVED colour — a stronger guarantee than
+ * two literals that happen to match, and one that survives a palette change.
+ *
+ * The claim is unchanged: this chip carries the proposal violet, and it is the same violet
+ * the viewport uses.
+ */
+ expect(overviewSrc, 'the provisional chip carries the proposal violet')
+ .toContain('var(--st-provisional)');
expect(overviewSrc, 'the chip row uses the violet tone').toMatch(
/tone-prov[^"]*"[^>]*data-testid="summary-count-provisional"/);
});
diff --git a/web/src/lib/engine/design/member-context.ts b/web/src/lib/engine/design/member-context.ts
index 29123775f..cb1cabd79 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/detailing/__tests__/element-status.test.ts b/web/src/lib/engine/detailing/__tests__/element-status.test.ts
index 3e62f586e..e8c266059 100644
--- a/web/src/lib/engine/detailing/__tests__/element-status.test.ts
+++ b/web/src/lib/engine/detailing/__tests__/element-status.test.ts
@@ -10,7 +10,7 @@
import { describe, expect, it } from 'vitest';
import {
- statusOf, reportElementStatus, ELEMENT_STATUS_ORDER,
+ statusOf, reportElementStatus, ELEMENT_STATUS_ORDER, NOT_FOR_CONSTRUCTION_STATUSES,
type DesignOutcomeSummary,
} from '../element-status';
import type { SceneModel, SceneSolid, SceneBar } from '../scene-model';
@@ -276,3 +276,106 @@ describe('what a member\'s top steel is', () => {
expect(r.hangerTopMembers).toEqual([]);
});
});
+
+/**
+ * A design refusal is not a reinforcement failure, and the rail must not say it is.
+ *
+ * ── What was wrong ─────────────────────────────────────────────────
+ *
+ * `FAILED` was tested first, unconditionally. A member whose design was REFUSED also fails
+ * verification — the refusal happened precisely because nothing in the code-permitted envelope
+ * verified — so `FAILED` won every time and `REFUSED` was unreachable. Measured in the browser
+ * on a starved column: `SEARCH_EXHAUSTED` ×8 in the design table, `failed 5 · refused 0` in the
+ * rail.
+ *
+ * The two mean different remedies, which is the whole reason they are two states. This module's
+ * own header says so:
+ *
+ * - the design was refused → change the section, or design by hand
+ *
+ * Reporting a refusal as a failure sends the reader to change reinforcement that no reinforcement
+ * can fix.
+ *
+ * ── What `FAILED` first was FOR, and still is ──────────────────────
+ *
+ * Its comment describes one case and only one: "a member can carry steel, have a VERIFIED design
+ * outcome from an earlier run, and still fail verification now — an edit to the section or the
+ * loads does exactly that." That case still preempts, because nothing else would name it: the
+ * outcome says VERIFIED and the truth is that it is not.
+ *
+ * So the rule is: `FAILED` wins when the OUTCOME DOES NOT ALREADY EXPLAIN the failure.
+ */
+describe('a refusal outranks the failure it caused', () => {
+ const failing = (outcome: DesignOutcomeSummary['outcome']): DesignOutcomeSummary => ({
+ outcome, verificationStatus: 'fail',
+ } as DesignOutcomeSummary);
+
+ it('SEARCH_EXHAUSTED is REFUSED, even though verification also fails', () => {
+ expect(statusOf(false, failing('SEARCH_EXHAUSTED'))).toBe('REFUSED');
+ // With steel too: a refused member should not have any, but the state is the outcome's.
+ expect(statusOf(true, failing('SEARCH_EXHAUSTED'))).toBe('REFUSED');
+ });
+
+ it('SECTION_INADEQUATE is REFUSED for the same reason', () => {
+ expect(statusOf(false, failing('SECTION_INADEQUATE'))).toBe('REFUSED');
+ });
+
+ it('UNSUPPORTED keeps its own name rather than collapsing into FAILED', () => {
+ // "A required check is not implemented for this member. No arrangement can pass." The
+ // verification failing is a consequence of that, not new information.
+ expect(statusOf(false, failing('UNSUPPORTED'))).toBe('UNSUPPORTED');
+ });
+
+ it('but a VERIFIED outcome that now fails is still FAILED — the case the guard exists for',
+ () => {
+ /*
+ * The regression this pairs with. An edit to the section or the loads makes a member with a
+ * VERIFIED design outcome fail verification now, and MODELLED there would show a green
+ * member the app knows is not green.
+ */
+ expect(statusOf(true, failing('VERIFIED'))).toBe('FAILED');
+ expect(statusOf(false, failing('VERIFIED'))).toBe('FAILED');
+ });
+
+ it('and so is a failing member with no design outcome at all', () => {
+ // Nothing else can be said about it, so the verification is the whole answer.
+ expect(statusOf(true, { verificationStatus: 'fail' } as DesignOutcomeSummary))
+ .toBe('FAILED');
+ });
+
+ it('the biaxial exception is untouched', () => {
+ /*
+ * A PROVISIONAL_BIAXIAL member fails the authoritative verifier BY CONSTRUCTION, and the
+ * narrow exception that keeps it out of FAILED predates this change and must survive it.
+ */
+ const provisional = {
+ outcome: 'PROVISIONAL_BIAXIAL', verificationStatus: 'fail',
+ verificationLimiting: ['biaxial'],
+ } as unknown as DesignOutcomeSummary;
+ expect(statusOf(true, provisional)).toBe('PROVISIONAL');
+ // And a proposal that ALSO fails on something else is still FAILED — the exception is narrow.
+ const alsoFlexure = {
+ outcome: 'PROVISIONAL_BIAXIAL', verificationStatus: 'fail',
+ verificationLimiting: ['biaxial', 'flexure'],
+ } as unknown as DesignOutcomeSummary;
+ expect(statusOf(true, alsoFlexure)).toBe('FAILED');
+ });
+
+ it('no path produces a state that could be read as finished work', () => {
+ /*
+ * The standing rule, checked across the change rather than at one call site: every state a
+ * failing or refused member can reach is in `NOT_FOR_CONSTRUCTION_STATUSES`, which the
+ * viewport legend, the sheets, the schedule and the report all consume.
+ */
+ const reached = [
+ statusOf(false, failing('SEARCH_EXHAUSTED')),
+ statusOf(false, failing('SECTION_INADEQUATE')),
+ statusOf(false, failing('UNSUPPORTED')),
+ statusOf(true, failing('VERIFIED')),
+ ];
+ for (const s of reached) {
+ expect(NOT_FOR_CONSTRUCTION_STATUSES, `${s} must never read as finished`).toContain(s);
+ expect(s).not.toBe('MODELLED');
+ }
+ });
+});
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/element-status.ts b/web/src/lib/engine/detailing/element-status.ts
index 550d8bc93..669893269 100644
--- a/web/src/lib/engine/detailing/element-status.ts
+++ b/web/src/lib/engine/detailing/element-status.ts
@@ -313,7 +313,37 @@ export function statusOf(
* biaxial one. A proposal that also fails on flexure or shear is a different situation and
* keeps FAILED, because then there is something wrong beyond the known limitation.
*/
- if (summary?.verificationStatus === 'fail' && !isKnownBiaxialLimitation(summary)) {
+ /*
+ * ── And the second exception: an outcome that already explains the failure ──
+ *
+ * A member whose design was REFUSED also fails verification — the refusal happened precisely
+ * because nothing in the code-permitted envelope verified. Testing FAILED unconditionally made
+ * `REFUSED` unreachable: measured on a starved column, the design table said
+ * `SEARCH_EXHAUSTED` ×8 while the rail said `failed 5 · refused 0`.
+ *
+ * The two states mean different remedies, which is the whole reason there are two of them —
+ * this file's own header says a refusal means "change the section, or design by hand". Calling
+ * it a failure sends the reader to change reinforcement that no reinforcement can fix.
+ *
+ * So FAILED preempts unless the outcome already names the reason. Listed explicitly rather than
+ * as "anything but VERIFIED", which is what the first version of this said and which broke the
+ * biaxial exception above: `PROVISIONAL_BIAXIAL` does NOT explain a flexure failure, and a
+ * proposal that fails on something beyond the known limitation must stay FAILED. Two existing
+ * tests caught it, which is the argument for the allowlist.
+ *
+ * `DEMAND_UNAVAILABLE` is not here either: a member with no demand that somehow fails
+ * verification is not a case the outcome accounts for.
+ *
+ * Both sides stay in `NOT_FOR_CONSTRUCTION_STATUSES`, so nothing here can make a refusal read
+ * as finished work.
+ */
+ const EXPLAINS_ITS_OWN_FAILURE = ['SEARCH_EXHAUSTED', 'SECTION_INADEQUATE', 'UNSUPPORTED'];
+ const outcomeExplainsIt = summary?.outcome !== undefined
+ && EXPLAINS_ITS_OWN_FAILURE.includes(summary.outcome);
+
+ if (summary?.verificationStatus === 'fail'
+ && !isKnownBiaxialLimitation(summary)
+ && !outcomeExplainsIt) {
return 'FAILED';
}
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/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/i18n/locales/ar.ts b/web/src/lib/i18n/locales/ar.ts
index 6e7e8824b..754f3835d 100644
--- a/web/src/lib/i18n/locales/ar.ts
+++ b/web/src/lib/i18n/locales/ar.ts
@@ -3441,6 +3441,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 c595e0e02..0c193356f 100644
--- a/web/src/lib/i18n/locales/de.ts
+++ b/web/src/lib/i18n/locales/de.ts
@@ -3461,6 +3461,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 ce180e7ab..7a2e31d89 100644
--- a/web/src/lib/i18n/locales/en.ts
+++ b/web/src/lib/i18n/locales/en.ts
@@ -5464,6 +5464,10 @@ const en: Record = {
'detailing.doc.dxf': 'Drawings / DXF',
'detailing.doc.xlsx': 'Bar schedule / XLSX',
'detailing.doc.notBuilt': 'No document built yet',
+ 'detailing.doc.contents.assemblies': 'Assemblies',
+ 'detailing.doc.contents.certificates': 'Certificates',
+ 'detailing.doc.contents.clauses': 'Clauses relied on',
+ 'detailing.doc.contents.assumptions': 'Assumptions',
'detailing.doc.revision': 'Revision {n}',
'detailing.doc.conflicts': '{n} unresolved conflict(s) — review draft only',
'detailing.doc.noCoordinated': 'No coordinated detailing exists. Generate and coordinate first; the pre-coordination reinforcement is not a substitute.',
@@ -5494,7 +5498,7 @@ const en: Record = {
'detailing.scene.outcome.SEARCH_EXHAUSTED': 'search exhausted',
'detailing.scene.outcome.UNSUPPORTED': 'unsupported',
'detailing.scene.workspace.title': '3-D reinforcement workspace',
- 'detailing.scene.workspace.close': 'Back to the model',
+ 'detailing.scene.workspace.close': 'Back to the workflow',
'detailing.scene.openWorkspace': 'Open 3-D workspace',
'detailing.scene.layers': 'Layers',
'detailing.scene.kind.column': 'Columns',
@@ -6048,6 +6052,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 510b06da3..fa681a82a 100644
--- a/web/src/lib/i18n/locales/es.ts
+++ b/web/src/lib/i18n/locales/es.ts
@@ -5451,6 +5451,10 @@ const es: Record = {
'detailing.doc.dxf': 'Planos / DXF',
'detailing.doc.xlsx': 'Planilla de doblado / XLSX',
'detailing.doc.notBuilt': 'Todavía no se generó ningún documento',
+ 'detailing.doc.contents.assemblies': 'Conjuntos',
+ 'detailing.doc.contents.certificates': 'Certificados',
+ 'detailing.doc.contents.clauses': 'Cláusulas invocadas',
+ 'detailing.doc.contents.assumptions': 'Supuestos',
'detailing.doc.revision': 'Revisión {n}',
'detailing.doc.conflicts': '{n} conflicto(s) sin resolver — solo borrador de revisión',
'detailing.doc.noCoordinated': 'No existe armado coordinado. Genere y coordine primero; la armadura previa a la coordinación no lo reemplaza.',
@@ -5481,7 +5485,7 @@ const es: Record = {
'detailing.scene.outcome.SEARCH_EXHAUSTED': 'búsqueda agotada',
'detailing.scene.outcome.UNSUPPORTED': 'no soportado',
'detailing.scene.workspace.title': 'Visor 3D de armaduras',
- 'detailing.scene.workspace.close': 'Volver al modelo',
+ 'detailing.scene.workspace.close': 'Volver al workflow',
'detailing.scene.openWorkspace': 'Abrir visor 3D',
'detailing.scene.layers': 'Capas',
'detailing.scene.kind.column': 'Columnas',
@@ -6040,6 +6044,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 d0c83c87f..953a0122d 100644
--- a/web/src/lib/i18n/locales/fr.ts
+++ b/web/src/lib/i18n/locales/fr.ts
@@ -3454,6 +3454,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 4684b0bc5..94cb44926 100644
--- a/web/src/lib/i18n/locales/hi.ts
+++ b/web/src/lib/i18n/locales/hi.ts
@@ -3453,6 +3453,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 7be420834..0a064cbca 100644
--- a/web/src/lib/i18n/locales/id.ts
+++ b/web/src/lib/i18n/locales/id.ts
@@ -3443,6 +3443,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 0cda4c7d3..20ebb9211 100644
--- a/web/src/lib/i18n/locales/it.ts
+++ b/web/src/lib/i18n/locales/it.ts
@@ -3454,6 +3454,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 ac7835983..6da3d247e 100644
--- a/web/src/lib/i18n/locales/ja.ts
+++ b/web/src/lib/i18n/locales/ja.ts
@@ -3433,6 +3433,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 4bc4b0d5e..305a49bdd 100644
--- a/web/src/lib/i18n/locales/ko.ts
+++ b/web/src/lib/i18n/locales/ko.ts
@@ -3440,6 +3440,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 59400ec51..ea0f28372 100644
--- a/web/src/lib/i18n/locales/pt.ts
+++ b/web/src/lib/i18n/locales/pt.ts
@@ -4064,6 +4064,10 @@ const pt: Translations = {
'detailing.doc.dxf': 'Desenhos / DXF',
'detailing.doc.noCoordinated': 'Não existe armadura coordenada. Gere e coordene primeiro; a armadura anterior à coordenação não a substitui.',
'detailing.doc.notBuilt': 'Ainda não foi gerado nenhum documento',
+ 'detailing.doc.contents.assemblies': 'Conjuntos',
+ 'detailing.doc.contents.certificates': 'Certificados',
+ 'detailing.doc.contents.clauses': 'Cláusulas invocadas',
+ 'detailing.doc.contents.assumptions': 'Pressupostos',
'detailing.doc.project': 'Armadura coordenada',
'detailing.doc.report': 'Relatório / PDF',
'detailing.doc.revision': 'Revisão {n}',
@@ -4139,6 +4143,14 @@ const pt: Translations = {
'detailing.qty': 'Qtd.',
'detailing.recordReview': 'Registrar revisão',
'detailing.review': 'Revisão do profissional',
+ // The four refusals `applyReview` raises and the store translates. They were missing here
+ // while `en` and `es` both had them, so a Portuguese user was refused in English — and now
+ // that `DocumentsSection` states them BEFORE the click, in `review-blockers`, the gap is on
+ // screen rather than only in an error. See `docs/handoffs/h1c-documents-audit.md` §4.
+ 'detailing.review.notConstructible': 'O conjunto está no estado {state}; só pode ser revisado a partir de CONSTRUCTIBLE.',
+ 'detailing.review.engineerRequired': 'Deve ser indicado o profissional que revisa.',
+ 'detailing.review.provisionalOutstanding': 'Há cálculos provisórios sem aceitação expressa: {keys}. Um cálculo provisório pode ser aceito, mas deve ser feito deliberadamente.',
+ 'detailing.review.provisionalNotAcknowledged': 'Falta a aceitação expressa dos cálculos provisórios.',
'detailing.review.notRecorded': 'Não foi possível registrar a revisão.',
'detailing.reviewedBy': 'Revisado por {engineer} em {at} (revisão {revision}).',
'detailing.revision': 'Rev. {n}',
@@ -4216,7 +4228,7 @@ const pt: Translations = {
'detailing.scene.unreinforcedCount': '{n} elemento(s) sem armadura — desenhados em laranja',
'detailing.scene.unresolved': 'Sem concreto desenhado em {n} elemento(s): {ids}',
'detailing.scene.unresolvedWhy': 'A sua seção não declara um retângulo b×h. Mostra-se a lacuna em vez de inventar uma seção.',
- 'detailing.scene.workspace.close': 'Voltar ao modelo',
+ 'detailing.scene.workspace.close': 'Voltar ao workflow',
'detailing.scene.workspace.title': 'Visualizador 3D de armaduras',
'detailing.schedule': 'Planilha de dobragem',
'detailing.schedule.purpose': 'Função',
@@ -5000,6 +5012,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 63c879a6c..929d2abdb 100644
--- a/web/src/lib/i18n/locales/ru.ts
+++ b/web/src/lib/i18n/locales/ru.ts
@@ -3445,6 +3445,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 dc3f31228..cf4541d5b 100644
--- a/web/src/lib/i18n/locales/tr.ts
+++ b/web/src/lib/i18n/locales/tr.ts
@@ -3453,6 +3453,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 e4712cb2a..35eff8f7d 100644
--- a/web/src/lib/i18n/locales/zh.ts
+++ b/web/src/lib/i18n/locales/zh.ts
@@ -3442,6 +3442,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.',
diff --git a/web/src/lib/store/__tests__/footing-document-slice.test.ts b/web/src/lib/store/__tests__/footing-document-slice.test.ts
index be43b30e2..f47394232 100644
--- a/web/src/lib/store/__tests__/footing-document-slice.test.ts
+++ b/web/src/lib/store/__tests__/footing-document-slice.test.ts
@@ -708,3 +708,109 @@ describe('the footing run cannot present superseded geometry as current', () =>
}
});
});
+
+/**
+ * A REFUSED review must not cost the user their document.
+ *
+ * `review()` called `retireDocument()` before `applyReview` had decided, so a refusal returned
+ * `false` AND superseded the document that had just been built. Measured in the documents stage
+ * before the fix: `doc-readiness` gone, `superseded-docs` grown by one, and an error where a
+ * document had been — a click that accomplished nothing cost an export.
+ *
+ * The retirement itself is correct and is kept: a recorded review changes the readiness a document
+ * may claim. Only its ORDER moved. Both directions are asserted here, because a test that only
+ * checked the refusal would pass on a `review()` that never retired anything at all.
+ */
+describe('a refused review leaves the document alone', () => {
+ beforeEach(() => {
+ modelStore.clear();
+ detailingStore.clear();
+ verificationStore.clear();
+ });
+
+ it('refuses for the engine\'s reason and keeps the current document', () => {
+ buildFootingModel();
+ const doc = designAndDocument();
+ const revision = doc.revision.number;
+ const supersededBefore = detailingStore.supersededDocuments.length;
+
+ /*
+ * `review()` acts on the SELECTED assembly, and `generateFloors()` does not select one. The
+ * first version of this test skipped the select and took the `if (!selected) return false`
+ * path — false with no `lastError`, which would have read as a refusal for entirely the wrong
+ * reason. So the selection is explicit, and the `lastError` assertion below is what forces it
+ * to be.
+ */
+ const target = detailingStore.assemblies[0] ?? doc.assemblies[0];
+ expect(target, 'the fixture must produce an assembly to review').toBeTruthy();
+ detailingStore.select(target.id);
+ if (!detailingStore.selected) {
+ /*
+ * Stated, not skipped. `detailingStore.assemblies` is `store.assemblies`, which the MEMBER
+ * detailing run populates — `generateFloors()` builds the floor families the document
+ * carries and leaves that list empty. So on this footing-only fixture there may be no
+ * selectable assembly, and `review()` would return `false` from `if (!selected)` with no
+ * `lastError` at all: a refusal for entirely the wrong reason, which is exactly what the
+ * first version of this test recorded as a pass.
+ *
+ * The ordering fix is covered end to end by `e2e/h1c-documents-flow.spec.ts`, on the model
+ * that does reach a selected assembly.
+ */
+ expect(detailingStore.document, 'and no review means nothing was retired').not.toBeNull();
+ return;
+ }
+
+ // An empty engineer is the refusal that needs no other state: `assembly.ts:488`.
+ const ok = detailingStore.review({
+ engineer: ' ',
+ at: '2026-08-22T10:00:00Z',
+ state: 'REVIEWED',
+ provisionalAcknowledged: true,
+ acknowledgedProvisional: [],
+ });
+
+ expect(ok, 'the review is refused').toBe(false);
+ expect(detailingStore.lastError, 'and says why').toBeTruthy();
+
+ expect(detailingStore.document, 'the document survives a refusal').not.toBeNull();
+ expect(detailingStore.document!.revision.number, 'and it is the same revision')
+ .toBe(revision);
+ expect(detailingStore.supersededDocuments.length, 'nothing was superseded')
+ .toBe(supersededBefore);
+ });
+
+ it('and a review that IS recorded still retires it, which is the point of retiring', () => {
+ buildFootingModel();
+ const doc = designAndDocument();
+ const revision = doc.revision.number;
+ const target = detailingStore.assemblies[0] ?? doc.assemblies[0];
+ detailingStore.select(target.id);
+ if (!detailingStore.selected) {
+ expect(detailingStore.document, 'no selectable assembly on this fixture').not.toBeNull();
+ return;
+ }
+
+ const ok = detailingStore.review({
+ engineer: 'Bauti',
+ at: '2026-08-22T10:00:00Z',
+ state: 'REVIEWED',
+ provisionalAcknowledged: true,
+ acknowledgedProvisional: [...detailingStore.provisional],
+ });
+
+ if (!ok) {
+ /*
+ * Stated rather than skipped. If this fixture cannot reach CONSTRUCTIBLE the positive
+ * control is unreachable here, and saying so is the difference between a test that proves
+ * the pair and one that proves half of it and reads like the whole.
+ */
+ expect(detailingStore.lastError, 'refused, so the positive control did not run')
+ .toBeTruthy();
+ expect(detailingStore.document, 'and the refusal still costs nothing').not.toBeNull();
+ return;
+ }
+ expect(detailingStore.document, 'a recorded review retires the old document').toBeNull();
+ expect(detailingStore.supersededDocuments.some((d) => d.revision.number === revision),
+ 'and keeps it as superseded rather than deleting it').toBe(true);
+ });
+});
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';
diff --git a/web/src/lib/store/detailing.svelte.ts b/web/src/lib/store/detailing.svelte.ts
index 991f8e80f..1fdf07a81 100644
--- a/web/src/lib/store/detailing.svelte.ts
+++ b/web/src/lib/store/detailing.svelte.ts
@@ -1378,9 +1378,6 @@ function createDetailingStore() {
*/
review(record: Omit): boolean {
if (!selected) return false;
- // A review changes the readiness a document may claim, so the previous one is no
- // longer current — even though the geometry is unchanged.
- retireDocument();
const r = applyReview(selected, record, provisionalKeys(selected));
if (!r.ok || !r.assembly) {
// The store is the locale boundary, so the engine's refusal is translated HERE. It used
@@ -1391,6 +1388,18 @@ function createDetailingStore() {
: t('detailing.review.notRecorded');
return false;
}
+ /*
+ * Retired only once the review is GOING to be recorded.
+ *
+ * A review changes the readiness a document may claim, so the previous one is no longer
+ * current even though the geometry is unchanged — that part was always right. What was
+ * wrong was doing it first: `retireDocument()` ran before `applyReview` decided, so a
+ * refused review superseded the document the user had just built and returned `false`.
+ * Measured in the documents stage: readiness gone, `supersededDocuments` grown by one, and
+ * an error where a document had been. A click that accomplished nothing cost them the
+ * export.
+ */
+ retireDocument();
replace(r.assembly);
lastError = null;
reviewOpen = false;
diff --git a/web/src/lib/three/rebar-scene.ts b/web/src/lib/three/rebar-scene.ts
index 9d452d938..86e6892c6 100644
--- a/web/src/lib/three/rebar-scene.ts
+++ b/web/src/lib/three/rebar-scene.ts
@@ -604,6 +604,23 @@ export function liveRebarSceneCensus(): RebarSceneCensus | null {
return liveScene?.census() ?? null;
}
+/**
+ * The conflict drawn in a marker slot of the open scene, for TEST HOOKS only.
+ *
+ * Read-only and allocation-free, exactly parallel to `liveRebarSceneCensus` above and for the
+ * same reason: `e2e-hooks.ts` cannot reach `liveScene`, and the alternative is a test that
+ * rebuilds the marker list and asserts against its own copy rather than against what is drawn.
+ *
+ * `conflictAt` resolves the SLOT, not an index into the document's conflicts — the compaction
+ * moves markers between slots when a filter is on — so a caller asking for slot 0 gets whatever
+ * is actually drawn there, which is what a click would have hit.
+ *
+ * Nothing in the application calls this.
+ */
+export function liveRebarSceneConflictAt(slot: number): SceneConflictMarker | null {
+ return liveScene?.conflictAt(slot) ?? null;
+}
+
const DEFAULTS = { diameterScale: 1, radialSegments: 6 };
/**
diff --git a/web/src/lib/utils/e2e-hooks.ts b/web/src/lib/utils/e2e-hooks.ts
index 103af8175..7b3dde3ac 100644
--- a/web/src/lib/utils/e2e-hooks.ts
+++ b/web/src/lib/utils/e2e-hooks.ts
@@ -30,12 +30,13 @@
import { modelStore, verificationStore, uiStore, historyStore } from '../store';
import { detailingStore } from '../store/detailing.svelte';
+import { rebarWorkspace } from '../store/rebar-workspace.svelte';
import { designRunStore } from '../store/design-run.svelte';
import { isSolverReady } from '../engine/wasm-solver';
import { getStructuralSolveCount } from './solve-counter';
import { runGlobalSolve } from '../engine/live-calc';
import {
- liveRebarSceneCensus, rebarSceneBuilds, type RebarSceneCensus,
+ liveRebarSceneCensus, liveRebarSceneConflictAt, rebarSceneBuilds, type RebarSceneCensus,
} from '../three/rebar-scene';
import { sceneCacheStats } from '../engine/detailing/scene-cache';
import { openTimeline, type OpenPhase } from './open-timeline';
@@ -211,6 +212,29 @@ export interface StabileoTestActions {
seedDetailing(assemblies: unknown): void;
selectAssembly(id: string): void;
reviewAssembly(record: unknown): boolean;
+ /**
+ * Select the conflict drawn in a marker slot, as clicking that marker would.
+ *
+ * A TEST MUTATOR, and the only route to `ConflictInspector` that a test has: the panel renders
+ * from `selection.conflict`, which is set by clicking a marker in the WebGL scene — raycast
+ * against the canvas, at a screen position no test can compute reliably.
+ *
+ * Returns false when the slot draws nothing, so a caller can tell "no conflict there" from
+ * "selected one".
+ */
+ selectConflict(slot?: number): boolean;
+ /**
+ * Resize a section, as the sections table would.
+ *
+ * A TEST MUTATOR. It exists because no fixture in the tree produces a REFUSED member: all three
+ * RC examples design to `VERIFIED` or `PROVISIONAL_BIAXIAL`, and there is no UI route to a
+ * section's dimensions — `ProSectionsTab` and `SectionChanger` carry no `data-testid` between
+ * them, and `BatchEditDialog` edits reinforcement.
+ *
+ * It changes a dimension and nothing else. The refusal that follows is the real engine's, on a
+ * section that genuinely cannot carry its demand — not a state written into a store.
+ */
+ updateSection(id: number, data: unknown): void;
toggleBarLock(barId: string): void;
computeDemands(): unknown;
codeCheck(): unknown;
@@ -374,6 +398,15 @@ export function installE2EHooks(): void {
selectAssembly: (id: string) => { detailingStore.select(id); },
reviewAssembly: (record: unknown) =>
detailingStore.review(record as never),
+ selectConflict: (slot = 0) => {
+ const conflict = liveRebarSceneConflictAt(slot);
+ if (!conflict) return false;
+ rebarWorkspace.selectConflict(conflict);
+ return true;
+ },
+ updateSection: (id: number, data: unknown) => {
+ modelStore.updateSection(id, data as never);
+ },
toggleBarLock: (barId: string) => { detailingStore.toggleLock(barId); },
loadExample: async (name: string) => { await modelStore.loadExample(name); },
/** Reset the selection between gestures — the position, not the subject. */
diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css
index 356e8ea25..3dad6821e 100644
--- a/web/src/styles/tokens.css
+++ b/web/src/styles/tokens.css
@@ -179,6 +179,24 @@
--st-text: var(--st-paper-2);
--st-text-2: var(--st-slate-2);
+ /*
+ RESERVED FOR INACTIVE AND DISABLED TEXT, and for glyphs and rules. Not for copy.
+
+ Measured against the four opaque grounds a panel can have: 4.03 on `--st-bg`, 3.74 on
+ `--st-surface`, 3.62 on `--st-surface-2`, 3.28 on `--st-surface-3`. It clears 4.5:1 on none
+ of them, and it cannot: preserving its hue, the first lightness that does is 58 %, while
+ `--st-text-2` sits at 63 %. A legal third level lands five points from the second, at which
+ point it is not a level. This palette has room for two text weights on a dark ground, not
+ three.
+
+ WCAG 2.1 §1.4.3 exempts the text of INACTIVE components, and §1.4.11 asks 3:1 of a glyph or
+ a rule, which this clears everywhere. So the value is right for what it now means, and the
+ 489 sites that used it as copy are the defect.
+
+ H1 migrated the 25 in the concrete design surface to `--st-text-2`. The rest — the root
+ components, `edu`, `App.svelte`, and the shared PRO chrome — are unmigrated and listed in
+ `docs/handoffs/h1-text-3-contrast-proposal.md`. THE VALUE IS UNCHANGED; this is a comment.
+ */
--st-text-3: #64798a;
--st-text-on-accent: #fff;
@@ -202,6 +220,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 ─────────────────────────────────────────────