uix(background): el velo era el dim del modal, y con on="light" combatía a su propia tinta

D-BG.20. El scrim tomaba prestado `--color-overlay`, que es `surface.backdrop`:
el dim de los modales (MD3/Radix/Vaul según el changelog), afinado POR MODO
porque una página clara necesita menos atenuación que una oscura. Es una
herramienta de ATENUACIÓN, y el scrim la usaba como tinta de LEGIBILIDAD — la
misma enfermedad que la escala `--opacity-*` prestada que se arregló esta misma
mañana, y que trajo tres defectos.

El primero, que la misma fotografía se leía distinto según el modo: 1,48:1 en
claro y 2,10:1 en oscuro. Una fotografía no cambia con el modo, y la legibilidad
no es un eje que el modo pueda mover. El segundo, un TECHO: el alpha propio de
la tinta (0,42 / 0,66) acotaba la escala entera, de modo que ni el peso máximo
alcanzaba AA y ningún paso nuevo podría haberlo alcanzado. El tercero es el que
no había visto y es el peor: con `on="light"` el velo era OSCURO bajo tinta
oscura. El scrim combatía a la tinta que existía para sostener.

La tinta del velo pasa a ser el SUELO de la tinta que `on` puso en vigor, que es
lo que D12 ya define: `--color-content-on-solid-contrast` bajo `on="dark"`,
`--color-content-on-solid` bajo `on="light"`, y `--color-surface-default` sin
`on`, porque ahí la copia toma la tinta de la página. Tres roles que ya existían
—ninguno inventado, §16.C— consumidos como capa 4 → capa 3 (§3) y sin alias. Con
la tinta opaca el peso ES el alpha, así que `strength` significa una sola cosa;
antes significaba dos, porque con `color="teal"` el ink ya era `--palette-solid`,
opaco, y sólo el default era translúcido.

La escala se re-afina por el TRABAJO de cada paso, medida con la matemática del
propio framework (`$color`: `apcaLc` + `wcagContrastRatio`) sobre la peor obra de
cada contexto: `xs` 0,08 · `sm` 0,13 · `md` 0,19 · `lg` 0,40 · `xl` 0,70. `xl` es
el único paso que promete legibilidad sobre CUALQUIER fotografía, y lo promete
contra los cuatro suelos que el framework ya usa —el criterio del par on-solid
(`lib/on-solid.ts`: APCA |Lc| ≥ 60 ∧ WCAG ≥ 3) y el AA 4,5 de §40— en ambos
contextos: `on="dark"` sobre foto blanca 6,45:1 con Lc 85, `on="light"` sobre
foto negra 8,29:1 con Lc 61. 0,70 es el mínimo que cierra los cuatro; el más
exigente, el APCA de `on="light"`, pedía 0,695. Los pasos por debajo son
atmósfera y el README lo dice: no son garantías.

Verificado en Chrome, no calculado: sin `on` el velo sale `oklch(0.9911 0 0 /
0.19)`, el suelo de la página en claro; con `on="dark"`, `#1c1917` al 19%; con
`on="light"`, BLANCO al 19% bajo tinta oscura, que es el arreglo; `xl` en
`on="light"` sobre foto negra mide 8,25:1 por píxel donde `$color` predijo 8,29.
Y el consumidor real: el hero en layout `background` pinta `#1c1917 / 0.19` con
titular blanco y da 1,48 / 5,17 — exactamente lo que daba antes en modo claro,
paridad a la cifra. Los heroes en modo oscuro se aclaran de 0,297 a 0,19, que es
el modo soltando una decisión que nunca fue suya.

El selector de contexto va envuelto en `:where()`, y no por estética: a pelo
llega a (0,4,0) y le gana a `[data-color]`, de modo que un
`<Background.Scrim color="teal">` dentro de un stack `on="dark"` pintaba el suelo
en vez de teal — el sistema de color abierto de §25 derrotado por un selector de
conveniencia. Envuelto, la familia baja a (0,2,0): el contexto gana al bloque
base por orden y pierde contra el color explícito. Medido antes y después.

Queda anotado que el vignette sigue consumiendo `--color-overlay`, y ahí es
correcto: es una trama que atenúa bordes, no un velo de legibilidad.

Esta decisión salió de leer la doctrina entera después de que el autor preguntara
si mi recomendación era conforme al sistema de color. No lo era: yo proponía una
tinta opaca única, que servía sólo a `on="dark"` e ignoraba los otros dos
contextos, y medía el contraste con un canvas WCAG-only en vez de con los suelos
del framework. Los tres puntos los corrigió la documentación.

Gates: audit PASS 0/0 · eidos-lint invalid 0, class-hooks 0 · rtl:check 0/180 ·
vitest eidos 434/435 (el rojo es `skin-media-player`, el de siempre) · `check`
con los mismos 72 errores preexistentes y ninguno propio · prettier limpio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent c691a88c1a
commit 4a9315d977

@ -16,14 +16,14 @@ real, 60 fps y 0 reflows, con dos defectos encontrados y arreglados — el
puntero que se desviaba con el scroll. Sin commitear todavía.
**Por dónde entrar mañana: [§Qué queda](#qué-queda).** Nada bloquea el uso del
componente. Lo que queda son **DOS firmas tuyas** (borrar `backdrop/` · qué hacer
con el techo de contraste del scrim) y **dos tareas de otros ejes**. La
componente. Lo que queda es **UNA firma tuya** (borrar `backdrop/`) y
**dos tareas de otros ejes**. La
verificación en navegador está cerrada, incluida la rama `@supports not`.
## ⚠️ LO PRIMERO: la fuente viva es el PLAN, no este fichero
**`docs/process/PLAN-background.md`** — decisiones firmadas `D-BG.1…D-BG.18` en
§6, y el registro de ejecución fase por fase en §7.bis…§7.decies, cada uno con
§6, y el registro de ejecución fase por fase en §7.bis…§7.duodecies, cada uno con
sus mediciones. Este handoff indexa; el plan manda.
## Qué queda
@ -85,31 +85,41 @@ por la aritmética de ahora), el puntero entero, `attach='fixed'`, y RTL.
`ui`, ni MediaSession, ni el slot `sound` de `$prefs`. Un clip que debe oírse es
contenido: `MediaPlayer`, que ya es ciudadano de `sound.media()`.
### 2. Lo que queda de tus firmas: DOS
De las cuatro, dos quedaron ejecutadas en §7.decies (la escala del scrim, ordenada
con tokens propios; la banda de `feature-split`, por sección y enmendada en los
tres sitios del plan más el de calidad). Quedan:
- ⛔ **Borrar `src/uix/eidos/components/backdrop/`** — D-BG.1 lo firmó y no lo usa
nadie (sólo se cita como baseline en el README de `Background` y en tres docs de
proceso, citas históricas que se conservan). **No lo he tocado**: la regla del
repo exige orden de borrado explícita, y «ejecuta el plan» no la sustituye. Una
palabra tuya y va.
- ⛔ **El techo de contraste del scrim, con las cifras corregidas.** Lo que había
escrito era falso por la mitad: el README asumía una tinta de α 0,66 y
`--color-overlay` trae **0,42**, así que el default no da 2,10:1 sobre foto
blanca sino **1,48:1**, y el paso más fuerte no da 4,42 sino **2,14**. Peor aún,
hay un **techo**: `xl` gasta la tinta entera y llega a **2,66:1**, de modo que
**ningún paso nuevo puede alcanzar AA** — el límite es el alpha del token, no la
escala. Las tres salidas, medidas por píxel:
1. **que el velo deje de heredar ese alpha** (con tinta opaca los mismos pesos
dan 5,45:1 a 0,65 y 9,22:1 a 0,80) — cambia qué ES un scrim y su relación con
el velo del modal;
2. **doctrinar que la respuesta es el scrim graduado**, y que sobre foto clara la
copia se pone en el extremo calmado;
3. dejarlo dicho y que cada consumidor oscurezca su propia foto.
Es decisión sobre la naturaleza del componente, no sobre un número.
### 1.ter ✅ D-BG.20 — la tinta del scrim (2026-08-18, §7.duodecies)
Firmada tras leer la doctrina entera, que corrigió mi propia recomendación. El
velo ya no toma prestado `--color-overlay` (= `surface.backdrop`, el dim modal,
afinado por MODO): su tinta es el **SUELO de la tinta que `on` puso en vigor**
(D12) — `#1c1917` bajo `on="dark"`, **blanco** bajo `on="light"`, la superficie de
la página sin `on`. Tres roles existentes, ninguno inventado.
- Arregla un defecto que no había visto: con `on="light"` el velo era OSCURO bajo
tinta oscura — **combatía a su propia tinta**.
- Quita el techo: con tinta opaca el peso ES el alpha, y **`xl` (0,70) promete
legibilidad sobre cualquier foto** contra los cuatro suelos del framework
(APCA ≥ 60 ∧ WCAG ≥ 3 de `on-solid.ts`, y AA 4,5 de §40) en ambos contextos:
6,45:1 / Lc 85 y 8,29:1 / Lc 61. Medido con `$color`, confirmado por píxel.
- Escala: `xs` 0,08 · `sm` 0,13 · **`md` 0,19** · `lg` 0,40 · **`xl` 0,70**.
- **Paridad exacta en el consumidor**: el hero en layout `background` sigue dando
1,48 / 5,17. Los heroes en modo oscuro se aclaran (0,297 → 0,19), que es lo
correcto: el modo no debía tocar la legibilidad.
- ⚠️ El selector de contexto va en `:where()`: a pelo llegaba a (0,4,0) y ganaba a
`[data-color]`, derrotando el sistema de color abierto (§25).
### 2. Lo que queda de tus firmas: UNA
De las cuatro, TRES quedaron ejecutadas: la escala del scrim (ordenada, con
tokens propios), la banda de `feature-split` (por sección) y el techo de contraste
(D-BG.20, §1.ter — resuelto de raíz cambiando la tinta, no subiendo un número).
Queda una:
- ⛔ **Borrar `src/uix/eidos/components/backdrop/`** — D-BG.1 lo firmó **con su
morfo incluido** (`src/uix/morfo/components/backdrop.ts`). Censado: cinco
ficheros, **cero consumidores** (no lo exporta el índice de eidos, no está en el
índice de morfos, sin langs, sin demo). Las menciones que quedan son texto: tres
docs de proceso y la §Baseline del README de `Background`, que se conservan.
**No lo he tocado**: la regla del repo exige orden de borrado explícita, y
«ejecuta el plan» no la sustituye. Una palabra tuya y va.
### 3. Tareas de otros ejes que este abrió

@ -1030,6 +1030,71 @@ efecto y dos líneas antes del `play()`. Ningún consumidor pasaba `muted` (el
no rompe a nadie. Gates: audit PASS 0/0 · vitest eidos 434/435 (el de siempre) ·
`check` sin errores propios · prettier limpio.
### 7.duodecies D-BG.20 — la tinta del scrim es el SUELO de la tinta en vigor (2026-08-18)
Firmada tras leer la doctrina entera (`reference.md` §3/§4/§25/§29/§39/§40,
`gradient-finish.md` §10 + D11/D12, el changelog de la escala de opacidad y del
cue `scrim` podado, `on-solid.ts`, `renderOnContextBlocks`). **Mi recomendación
anterior no era conforme y la doctrina la corrigió en tres puntos.**
**El diagnóstico que sí se sostuvo**: `--color-overlay` ES `surface.backdrop`, el
dim modal (MD3/Radix/Vaul según el changelog), afinado POR MODO porque una página
clara necesita menos atenuación que una oscura. Es una herramienta de ATENUACIÓN,
y el scrim la tomaba prestada como tinta de LEGIBILIDAD — la misma enfermedad que
la escala `--opacity-*` prestada que se arregló horas antes.
**Lo que la doctrina corrigió:**
1. **No es «una tinta opaca» elegida por mí: es el SUELO del `on`.** D12 ya define
el contexto de tinta (`on='dark'` → `--color-content-on-solid`; `on='light'` →
`--color-content-on-solid-contrast`), así que el velo es el OTRO miembro de ese
par, y sin `on` es el suelo de la tinta de la página
(`--color-surface-default`). Tres roles EXISTENTES, ninguno inventado (§16.C),
capa 4 → capa 3 (§3), sin alias (`no-token-aliases`). Mi token único servía
sólo a `on='dark'` e ignoraba los otros dos casos — y con `on='light'` el velo
era OSCURO bajo tinta oscura: **el scrim combatía a su propia tinta**. Ése era
el defecto más grave de los dos, y no lo había visto.
2. **El contraste se mide con el criterio del framework**: `on-solid.ts` (APCA
|Lc| ≥ 60 ∧ WCAG ≥ 3) y §40 (AA 4,5 para texto), con la matemática de `$color`,
no con un canvas WCAG-only como el que usé antes.
3. **`--opacity-scrim` sigue existiendo** (0,45, rol de opacidad de ELEMENTO, lo
leen `chart` y el mesh): el `md: 0.45` que shipeé por la mañana colisionaba con
él por valor. Con el suelo firmado, `md` pasa a 0,19 y la colisión se disuelve.
**La escala, por trabajo y medida** (`$color` sobre la peor obra de cada
contexto): `xs` 0,08 · `sm` 0,13 · **`md` 0,19** (default) · `lg` 0,40 · **`xl`
0,70**. Con tinta opaca el peso ES el alpha. `xl` es **el único paso que promete
legibilidad sobre CUALQUIER fotografía**, y lo promete contra los cuatro suelos a
la vez: `on='dark'` sobre foto blanca **6,45:1 / Lc 85**; `on='light'` sobre foto
negra **8,29:1 / Lc 61**. 0,70 es el peso mínimo que cierra los cuatro (el más
exigente, el APCA de `on='light'`, pedía 0,695).
**Verificado en Chrome, no calculado**: sin `on` el velo es
`oklch(0.9911 0 0 / 0.19)` (el suelo de la página en claro) · `on='dark'` →
`#1c1917 / 0.19` · `on='light'` → **blanco** `/ 0.19` con tinta oscura encima
(el arreglo) · `xl` en `on='light'` sobre foto negra medido **8,25:1** por píxel,
donde `$color` predijo 8,29 · un `data-color='teal'` dentro de un stack
`on='light'` pinta teal y no el suelo (**la precedencia**). Y el consumidor real:
el hero en layout `background` (`on='dark'`, `md`) pinta `#1c1917 / 0.19` con
titular blanco y da **1,48 / 5,17** — idéntico a lo que daba antes en modo claro,
**paridad exacta**. Los heroes en modo OSCURO se aclaran de 0,297 a 0,19, que es
el modo soltando una decisión que nunca fue suya.
⚠️ **La especificidad casi rompe §25.** El selector de contexto a pelo llega a
(0,4,0) y ganaba a `[data-color]` (0,3,0): un `<Background.Scrim color="teal">`
dentro de un stack `on='dark'` pintaba el suelo en vez de teal — es decir, el
sistema de color abierto (§25) derrotado por un selector de conveniencia.
Envuelto en `:where()` la familia entera baja a (0,2,0): el contexto gana al
bloque base por ORDEN y pierde contra el color explícito. Medido antes y después.
Fuera de alcance, anotado: el **vignette** sigue consumiendo `--color-overlay`
(`background.css`), y ahí es correcto — es una trama que atenúa bordes, no un
velo de legibilidad.
Gates: audit PASS 0/0 · eidos-lint invalid 0 / class-hooks 0 · `rtl:check` 0/180 ·
vitest eidos 434/435 (el `skin-media-player` de siempre) · `check` con los 72
errores preexistentes y ninguno propio.
---
## 8. Riesgos y cómo se acotan

@ -77,38 +77,50 @@ above resolves to the on-solid ink. The documented limits of that context are
D12's, unchanged: nested components resolve their OWN tokens, and portaled
content escapes.
**And no scrim weight is enough for a bright photograph** — measured in Chrome on
2026-08-18, white text over the veil each step actually paints, composited in a
canvas and read back per pixel:
| `strength` | weight | effective α | over a DARK photo | over mid-grey | over a WHITE photo |
| -------------- | ------ | ----------- | ----------------- | ------------- | ------------------ |
| `xs` | 0.20 | 0.084 | 13.20:1 | 4.48:1 | 1.18:1 ✗ |
| `sm` | 0.30 | 0.126 | 13.35:1 | 4.73:1 | 1.29:1 ✗ |
| (default) `md` | 0.45 | 0.189 | 13.58:1 | 5.17:1 | **1.48:1** ✗ |
| `lg` | 0.65 | 0.273 | 13.92:1 | 5.90:1 | 1.82:1 ✗ |
| `xl` | 1.00 | 0.420 | 14.84:1 | 7.51:1 | **2.66:1** ✗ |
The weight is relative to an ink that is **already translucent**:
`--color-overlay` resolves to `rgba(28, 25, 23, 0.42)`, so the effective α is
`weight × 0.42` and the default lands at 0.189 — the hero's shipped value to the
digit. The consequence is a **ceiling**: even `xl`, which spends the whole ink,
reaches 2.66:1 over white. Over dark or mid artwork the scale clears AA
comfortably; over a bright one **no weight can**, and adding steps cannot change
that — the ink's own alpha is the bound. An opaque ink at the same weights would
reach 5.45:1 at 0.65 and 9.22:1 at 0.80 (measured the same way), which is the
shape any fix would take, and it is a decision about what a scrim IS, not a
number to raise.
So: reach for a graded scrim and put the copy on the calm end, or darken the
artwork itself. Stated because a framework that hides this ships a hero that is
illegible on somebody else's photograph.
> An earlier version of this table read 0.297 / 0.429 / 0.528 and 2.10:1 for the
> default. Those came from an ink carrying 0.66; the token carries 0.42, so the
> real numbers are the ones above and the problem is **worse** than was written,
> not better. Corrected 2026-08-18 by composing the measured veil per pixel
> instead of computing it.
**The scrim's ink is the FLOOR of the ink in force**, and that is the whole
design. `on` puts an ink in force (D12, `theming/reference.md` §39); the veil
underneath it is the other member of that pair, opaque — so `strength` IS the
alpha, one axis with one meaning:
| stack | ink above (D12) | the veil under it |
| ------------ | ------------------- | --------------------------------------------- |
| `on="dark"` | `on-solid` (white) | `--color-content-on-solid-contrast` (#1c1917) |
| `on="light"` | `on-solid-contrast` | `--color-content-on-solid` (white) |
| no `on` | the page's own ink | `--color-surface-default` (the page's floor) |
Five weights, and each one names the WORK it does. Measured with the framework's
own maths (`$color`'s `apcaLc` + `wcagContrastRatio`) at the worst artwork of each
context, and confirmed per pixel in Chrome on 2026-08-18:
| `strength` | weight | `on="dark"` over a WHITE photo | `on="light"` over a BLACK photo |
| -------------- | ------ | ------------------------------ | ------------------------------- |
| `xs` | 0.08 | 1.17:1 | 1.05:1 |
| `sm` | 0.13 | 1.31:1 | 1.09:1 |
| (default) `md` | 0.19 | 1.49:1 | 1.33:1 |
| `lg` | 0.40 | 2.52:1 | 3.05:1 |
| `xl` | 0.70 | **6.45:1** · Lc 85 | **8.29:1** · Lc 61 |
**`xl` is the only step that promises legibility over ANY photograph**, and it
promises it against both floors the framework already uses: the on-solid pair
criterion (`lib/on-solid.ts` — APCA |Lc| ≥ 60 **and** WCAG ≥ 3) and §40's AA 4.5
for body text, in **both** ink contexts, over the worst artwork each one can
meet. 0.70 is the smallest weight that clears all four. The steps below it are
atmosphere, not guarantees: reach for `xl` when the artwork is unknown, or put the
copy on the calm end of a graded scrim when you own the picture. Stated because a
framework that hides this ships a hero that is illegible on somebody else's photo.
> **What this replaced, and why it was a defect and not a number.** The ink used to
> be `--color-overlay` — which is `surface.backdrop`, the modal dim (MD3/Radix/Vaul),
> tuned PER MODE: `rgb(28 25 23 / 0.42)` light, `rgb(0 0 0 / 0.66)` dark. Borrowed as
> a legibility tool it brought two defects. **The same photograph read differently
> per mode** (1.48:1 light vs 2.10:1 dark) — a photograph does not change with the
> mode, and legibility is not an axis the mode gets to move. And its own alpha
> **capped the scale**: even weight 1 could only reach 2.66:1, so no step could ever
> promise AA. `on="light"` was worse than capped: the veil was DARK under dark ink,
> fighting the very ink it was there to carry. The default's paint is unchanged in
> light mode to the digit (0.189 → 0.19, because the old backdrop's hue WAS this
> floor); dark-mode heroes lighten from 0.297 to 0.19, which is the mode letting go
> of a decision that was never its own.
## Media, and how it fails
@ -323,17 +335,17 @@ is ported from them.
## Comparativa
| Capability | UIX `Background` | Mantine (`BackgroundImage` + `Overlay`) | Vuetify `v-parallax` | react-scroll-parallax `ParallaxBanner` | Aceternity / Magic UI / shadcn.io |
| --------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------- | -------------------- | -------------------------------------- | ------------------------------------- |
| Stacked layers with blend / weight / mask | ✓ one primitive | ✗ (two components, no stack) | ✗ | ✓ (layers array) | ✗ (one effect = one wrapper) |
| Host adopted automatically | ✓ (`:has`, foundation) | ✗ (the parent must be positioned; silent when it is not) | n/a | ✗ | ✗ |
| Patterns from theme tokens | ✓ 8, re-tint per theme + mode | ✗ | ✗ | ✗ | ✓ but colours hand-picked per snippet |
| Scrim / overlay | ✓ (flat · graded · frosted, semantic weights) | ✓ `Overlay` (color + opacity + blur) | ✗ | ✗ | ad hoc |
| Named themeable gradients | ✓ (`colors="aurora"` \| stop list) | ✗ | ✗ | ✗ | hex per snippet |
| Reduced-motion / forced-colors / contrast by construction | ✓ | ✗ | ✗ | `disabled` by hand | rare |
| Ink context for the content above | ✓ (`on`, D12) | ✗ | ✗ | ✗ | ✗ |
| Declared contract (parts, `aria-hidden`, tokens, guards) | ✓ morfo + audit + lint | ✗ | ✗ | ✗ | ✗ |
| Breadth of exotic effects | the `Ambient` pack (32) | ✗ | ✗ | ✗ | ✓ (dozens, copy-paste) |
| Capability | UIX `Background` | Mantine (`BackgroundImage` + `Overlay`) | Vuetify `v-parallax` | react-scroll-parallax `ParallaxBanner` | Aceternity / Magic UI / shadcn.io |
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------- | -------------------------------------- | ------------------------------------- |
| Stacked layers with blend / weight / mask | ✓ one primitive | ✗ (two components, no stack) | ✗ | ✓ (layers array) | ✗ (one effect = one wrapper) |
| Host adopted automatically | ✓ (`:has`, foundation) | ✗ (the parent must be positioned; silent when it is not) | n/a | ✗ | ✗ |
| Patterns from theme tokens | ✓ 8, re-tint per theme + mode | ✗ | ✗ | ✗ | ✓ but colours hand-picked per snippet |
| Scrim / overlay | ✓ (flat · graded · frosted; ink = the floor of the ink in force, one step measured to clear AA over any photo) | ✓ `Overlay` (color + opacity + blur — the consumer picks the number) | ✗ | ✗ | ad hoc |
| Named themeable gradients | ✓ (`colors="aurora"` \| stop list) | ✗ | ✗ | ✗ | hex per snippet |
| Reduced-motion / forced-colors / contrast by construction | ✓ | ✗ | ✗ | `disabled` by hand | rare |
| Ink context for the content above | ✓ (`on`, D12) | ✗ | ✗ | ✗ | ✗ |
| Declared contract (parts, `aria-hidden`, tokens, guards) | ✓ morfo + audit + lint | ✗ | ✗ | ✗ | ✗ |
| Breadth of exotic effects | the `Ambient` pack (32) | ✗ | ✗ | ✗ | ✓ (dozens, copy-paste) |
The last row is the deliberate split, not a gap: an effect is decoration and
lives in the pack tier; the HOST is contract surface and lives here. A layer is
@ -410,30 +422,33 @@ emitting per frame. The one user act in reach, pausing, belongs to the
not a valid `background-image` layer — through the longhand it computes to
`none` and paints nothing.
- **The scrim mixes its weight into the paint** instead of setting `opacity`: an
`opacity` on the layer would fade the `backdrop-filter` behind it too. The
weights are RELATIVE to an ink that is already translucent — `--color-overlay`
resolves to `rgba(28, 25, 23, 0.42)`, so the default `md` weight paints
`0.42 × 0.45 = 0.189` (measured). That is the hero's shipped value to the digit
(`background: var(--color-overlay); opacity: var(--opacity-scrim)`), which is
why the axis is named by WEIGHT and not by alpha — and also why the scale has a
ceiling it cannot argue with (see the table above).
- **The weight scale is the VEIL'S OWN, not `--opacity-*`.** Those tokens name how
opaque an ELEMENT is; borrowed as veil names they imported an ordering that
means something else, and the result was not monotonic: `subtle` (0.80) veiled
MORE than `overlay` (0.65), and `overlay` tied with `muted`. A consumer reading
`strength="subtle"` got the heaviest veil in the set. Now five steps
(`--background-scrim-strength-xs`…`-xl`) ordered by construction, with `md`
holding the 0.45 the hero shipped so the default paints exactly what it painted
before — verified: α 0.084 · 0.126 · 0.189 · 0.273 · 0.420, strictly
increasing. Renaming was the honest fix and it is an API change, taken while the
only consumer was the demo; every block uses the default.
`opacity` on the layer would fade the `backdrop-filter` behind it too.
- **The ink is the FLOOR of the ink in force, not the modal's dim.** A scrim is a
legibility tool, so its colour is the floor of whatever `on` put in force — three
existing roles, no new one invented (`reference.md` §16.C). Borrowing
`--color-overlay` (`surface.backdrop`) made the same photograph read differently
per mode and capped the scale below AA; the table above records both, measured.
The FOLLOW-ON is a `:where()` on the context selector: at plain specificity it
reached (0,4,0) and beat an explicit `color`, so a
`<Background.Scrim color="teal">` inside an `on="dark"` stack painted the floor
instead of teal. Wrapped, the family sits at (0,2,0), the context wins over the
base block by ORDER, and `[data-color]` (0,3,0) wins over both — measured.
- **The weight scale is the VEIL'S OWN, and each step names a JOB.** The weights
used to borrow `--opacity-*`, which names how opaque an ELEMENT is; read as veil
names that scale was not even monotonic (`subtle` 0.80 veiled MORE than `overlay`
0.65, which tied with `muted` — a consumer asking for `subtle` got the heaviest
veil in the set). Now five steps, ordered by construction, with `md` holding the
paint the hero shipped and `xl` carrying the only promise the component makes
about contrast. The numbers come from `$color`'s own maths against the framework's
own floors, not from taste — see the table.
- **A layer reads the shared palette only when IT carries the colour.**
`--palette-*` inherits, so an unguarded read would make a `Scrim` inside a
`<Card color="teal">` paint a teal veil instead of a veil. The presence guard
(`[data-background-layer][data-color]`) is the same one THM-2 uses one level
up. Measured in Chrome: inside a teal Card the scrim resolves `--color-overlay`
and the pattern `--color-primary-solid`; with `<Background.Scrim color="teal">`
it resolves teal.
up. Measured in Chrome: inside a teal Card the scrim resolves its own floor and
the pattern `--color-primary-solid`; with `<Background.Scrim color="teal">` it
resolves teal — including inside an `on="dark"` stack, where the explicit colour
beats the ink context (the `:where()` above is what buys that).
- **The media fit lives in the recipe**, not in each consumer's scoped style —
the layer IS the box, so `object-fit: cover` on a slotted `<img>`/`<video>`
belongs to it.
@ -480,11 +495,11 @@ emitting per frame. The one user act in reach, pausing, belongs to the
## Gaps
| Gap | Disposition |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ~~The `strength` scale is not ordered by veil weight~~ — `subtle` (0.80) veiled MORE than `overlay` (0.65), which tied with `muted`. Measured 2026-08-17 | **RESUELTO 2026-08-18** — the veil got its own five-step scale (`xs`…`xl`), ordered by construction, `md` holding the shipped 0.45. See §Decisiones |
| **A bright photograph is illegible at EVERY weight, and the scale has a ceiling** — 1.48:1 at the default, 2.66:1 at `xl`, which already spends the whole ink. Re-measured per pixel 2026-08-18 (the old 2.10 / 4.42 assumed an ink at 0.66) | **decisión del autor** — no extra step can fix it: the bound is the ink's own alpha (0.42). Either the veil stops inheriting that alpha (an opaque ink reaches 5.45:1 at weight 0.65, 9.22:1 at 0.80 — measured), which changes what a scrim IS and its relation to the modal veil, or the doctrine is that the graded scrim is the answer |
| Demo page + the 9-tab harness | **implementar** — F4 |
| The hero's `background` layout still hand-builds its layers | **implementar** — F2, once `Image`/`Video` exist |
| `Ambient` reading the context to pause its scene | **diferir** — a task of the PACK (D-BG.8), after F2 |
| Per-layer scroll velocity in a nested scroller | **descartar** — the fondo does not orchestrate content; `ScrollFrames` owns scrub, and pinned storytelling is another initiative |
| Gap | Disposition |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ~~The `strength` scale is not ordered by veil weight~~ — `subtle` (0.80) veiled MORE than `overlay` (0.65), which tied with `muted`. Measured 2026-08-17 | **RESUELTO 2026-08-18** — the veil got its own five-step scale (`xs`…`xl`), ordered by construction, `md` holding the shipped 0.45. See §Decisiones |
| ~~A bright photograph is illegible at EVERY weight, and the scale has a ceiling~~ — 1.48:1 at the default and 2.66:1 at the top, because the borrowed ink's own alpha bounded it. Re-measured per pixel 2026-08-18 | **RESUELTO 2026-08-18** — the veil's ink became the FLOOR of the ink in force (opaque, three existing roles), so weight IS alpha and the ceiling is gone: `xl` clears both framework floors in both contexts over the worst artwork (6.45:1 / Lc 85 and 8.29:1 / Lc 61). The same change fixed `on="light"`, whose veil used to be DARK under dark ink. See §Legibility |
| Demo page + the 9-tab harness | **implementar** — F4 |
| The hero's `background` layout still hand-builds its layers | **implementar** — F2, once `Image`/`Video` exist |
| `Ambient` reading the context to pause its scene | **diferir** — a task of the PACK (D-BG.8), after F2 |
| Per-layer scroll velocity in a nested scroller | **descartar** — the fondo does not orchestrate content; `ScrollFrames` owns scrub, and pinned storytelling is another initiative |

@ -3,7 +3,7 @@
* Eidos `<Background.Scrim>` — the veil that makes content legible over
* whatever the layers below are painting.
*
* <Background.Scrim /> <!-- flat, --color-overlay -->
* <Background.Scrim /> <!-- flat, the ink's floor -->
* <Background.Scrim gradient="to-t" /> <!-- clears towards the top -->
* <Background.Scrim blur="md" strength="xs" /> <!-- frost, barely tinted -->
*

@ -9,8 +9,8 @@
*
* Everything paints from tokens: the accent through the `data-color` palette
* forward (`--palette-solid`), the rules from `--color-border-*`, the mesh from
* the canonical `--gradient-aurora`, the veil from `--color-overlay` and the
* semantic opacity scale. No hand-picked colour, so a theme swap carries the
* the canonical `--gradient-aurora`, and the veil from the FLOOR of the ink the
* stack's `on` put in force. No hand-picked colour, so a theme swap carries the
* whole decoration with it.
*
* Public tokens: `--background-*`. Internal: `--_background-*`.
@ -289,8 +289,9 @@
*/
[data-background-layer][data-kind='scrim'] {
/* Default ink: the canonical overlay of the theme — the same token the modal
veil uses. NOT `--palette-*`: a scrim with no `color` of its own inside a
/* Default ink: the FLOOR of the ink in force. With no `on`, the copy above
takes the page's own ink, whose floor is the page's own surface. NOT
`--palette-*`: a scrim with no `color` of its own inside a
`<Card color="teal">` would otherwise inherit teal and stop being a veil
(the presence guard, as above). */
--_background-scrim-ink: var(--background-scrim-color);
@ -306,6 +307,21 @@
);
}
/* The ink FOLLOWS the ink context (D12, `theming/reference.md` §39). `on='dark'`
binds the content roles to the on-solid ink, so the veil that has to carry it
is that pair's other member — and the other way round for `on='light'`. Wrapped
in `:where()` so the whole family stays at (0,2,0): the context must beat the
base block (it does, by order) and must LOSE to an explicit `color`, which sits
at (0,3,0) below. Measured before wrapping: at plain specificity the context
selector reached (0,4,0) and a `<Background.Scrim color="teal">` inside an
`on='dark'` stack painted the floor instead of teal. */
:where([data-background][data-on='dark']) [data-background-layer][data-kind='scrim'] {
--_background-scrim-ink: var(--background-scrim-color-on-dark);
}
:where([data-background][data-on='light']) [data-background-layer][data-kind='scrim'] {
--_background-scrim-ink: var(--background-scrim-color-on-light);
}
[data-background-layer][data-kind='scrim'][data-color],
[data-background-layer][data-kind='scrim'][data-color-custom] {
--_background-scrim-ink: var(--palette-solid, var(--background-scrim-color));

@ -264,7 +264,12 @@ export type BackgroundVideoProps = Omit<HTMLAttributes<HTMLDivElement>, 'childre
export type BackgroundScrimProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> &
BackgroundLayerBaseProps & {
/** Scrim ink — any role / scale / raw value. @default `--color-overlay` */
/**
* Scrim ink — any role / scale / raw value. Defaults to the FLOOR of the
* ink `on` put in force (white under `on="light"`, `#1c1917` under
* `on="dark"`, the page's surface with no `on`), which is what makes
* `strength` mean alpha.
*/
color?: ComponentColorProp;
/** How much it veils. @default 'md' */
strength?: BackgroundScrimStrength;

@ -2786,12 +2786,14 @@
--background-pattern-rings-gap: 4rem;
--background-fade-size: 75% 75%;
--background-fade-at: 50% 35%;
--background-scrim-color: var(--color-overlay);
--background-scrim-strength-xs: 0.2;
--background-scrim-strength-sm: 0.3;
--background-scrim-strength-md: 0.45;
--background-scrim-strength-lg: 0.65;
--background-scrim-strength-xl: 1;
--background-scrim-color: var(--color-surface-default);
--background-scrim-color-on-dark: var(--color-content-on-solid-contrast);
--background-scrim-color-on-light: var(--color-content-on-solid);
--background-scrim-strength-xs: 0.08;
--background-scrim-strength-sm: 0.13;
--background-scrim-strength-md: 0.19;
--background-scrim-strength-lg: 0.4;
--background-scrim-strength-xl: 0.7;
--background-gradient-drift-duration: 24s;
--background-pause-offset: var(--space-3);
--background-pause-z: var(--z-index-raised);

@ -3813,19 +3813,33 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// ── Fade (the edge mask) ────────────────────────────────────────────
'fade-size': '75% 75%',
'fade-at': '50% 35%',
// ── Scrim — the canonical overlay ink + the veil's OWN weight scale ──
// The weights used to borrow `--opacity-*`, which names how opaque an
// ELEMENT is; borrowed as veil names that scale came out unordered
// (`subtle` 0.80 veiled MORE than `overlay` 0.65, and `overlay` tied with
// `muted`). A veil is its own axis and gets its own steps, ordered by
// construction. `md` keeps the 0.45 the hero shipped, so the default
// paints exactly what it painted before.
'scrim-color': 'var(--color-overlay)',
'scrim-strength-xs': '0.2',
'scrim-strength-sm': '0.3',
'scrim-strength-md': '0.45',
'scrim-strength-lg': '0.65',
'scrim-strength-xl': '1',
// ── Scrim — the FLOOR of the ink in force, and the veil's own weights ──
//
// The ink: a scrim exists so the copy above it stays legible, so its
// colour is the FLOOR of whatever ink `on` put in force (D12, §39). It
// used to be `--color-overlay`, which is `surface.backdrop` — the modal
// dim (MD3/Radix/Vaul), tuned PER MODE because a light page needs less
// attenuation than a dark one. Borrowed as a legibility tool that brought
// two defects: the same photograph read 1.48:1 in light and 2.10:1 in
// dark (a photograph does not change with the mode), and its own alpha
// (0.42 / 0.66) capped the whole scale at 2.66:1 — no weight could reach
// AA. Now the ink is opaque and picked by context, so weight IS alpha.
'scrim-color': 'var(--color-surface-default)',
'scrim-color-on-dark': 'var(--color-content-on-solid-contrast)',
'scrim-color-on-light': 'var(--color-content-on-solid)',
// The weights: five steps by the WORK each one does, measured with the
// framework's own maths (`$color` apcaLc + wcagContrastRatio) over the
// worst artwork of each context. `md` is the default and holds the 0.189
// the hero shipped — in light mode, identical to the digit, because the
// old backdrop's hue WAS this floor. `xl` is the only step that promises
// legibility over ANY photograph: it clears BOTH framework floors in BOTH
// contexts (on-solid's APCA ≥ 60 ∧ WCAG ≥ 3, and §40's AA 4.5 for text) —
// measured `on='dark'` 6.45:1 / Lc 85, `on='light'` 8.29:1 / Lc 61.
'scrim-strength-xs': '0.08',
'scrim-strength-sm': '0.13',
'scrim-strength-md': '0.19',
'scrim-strength-lg': '0.4',
'scrim-strength-xl': '0.7',
// ── Gradient drift (the `animate` layer) ────────────────────────────
'gradient-drift-duration': '24s',
// ── The pause control (WCAG 2.2.2) ──────────────────────────────────

Loading…
Cancel
Save

Powered by TurnKey Linux.