diff --git a/docs/audit/theming/radio-group.md b/docs/audit/theming/radio-group.md index eef6a5493..d5c88581d 100644 --- a/docs/audit/theming/radio-group.md +++ b/docs/audit/theming/radio-group.md @@ -71,6 +71,23 @@ instancia (veredictos §5 de `card`, `tags-input`, `avatar`). ## 3. Privados de la receta — ¿de dónde sale su valor? + + +> **Nota 2026-08-26 (firma §14).** La receta declara ahora cuatro alias más sobre +> `[data-radio-group-selection-indicator]`: +> `--_indicator-{x,y,w,h}: var(--_radio-group-indicator-{x,y,w,h})`. Son el +> cableado del **canal de valor** que `MeasuredIndicator` publica: la capa +> compartida escribe el rect en el espacio de ESTE componente (el rect es un +> hecho de esta instancia), y `lib/sliding-indicator.css` —que no puede deletrear +> el nombre del anfitrión— lo lee por el alias, igual que +> `calendar-surface.css` lee `--_calendar-surface-accent`. **Ninguna cifra de +> esta hoja se mueve**: alcance 98 %, 59 knobs, el reparto idéntico. Las cuatro +> `--_radio-group-indicator-*` sí se sumarán a la lista de «consumidos y no +> declarados» de abajo al regenerar, que es justo lo que son. No se acuñan: un +> tema que fije una de las cuatro congela la píldora. + + + | privado | declaraciones | valor(es) | origen | ¿deriva de un público? | | --- | ---: | --- | --- | :-: | | `--_radio-group-palette-text` | 4 | `var(--radio-group-dot-fg)`, `var(--_radio-group-palette-solid)` | private, public | no | diff --git a/docs/audit/theming/tabs.md b/docs/audit/theming/tabs.md index 3e69d2f10..4c4425efc 100644 --- a/docs/audit/theming/tabs.md +++ b/docs/audit/theming/tabs.md @@ -12,6 +12,22 @@ ## 1. Knobs fuera de alcance + + +> ⚠ **Todo lo medido de esta hoja es del 2026-08-25 y quedó SUPERADO el +> 2026-08-26** por la firma §14 («MeasuredIndicator con nombre poseído»). Los +> dos knobs de §1.1 y las dos filas propuestas en §4.1 hablan de +> `var(--indicator-w, 0px)` / `var(--indicator-h, 0px)`: ese nombre ya no +> existe. La capa compartida escribe ahora en el espacio del ANFITRIÓN, +> `--_tabs-indicator-{x,y,w,h}`, y el censo los lee como lo que son —**canal**, +> fuera del ratio—. Cifras de hoy: **alcance 100 %**, global 2 → 0, canal +> 0 → 2, 77 knobs iguales; las dos entradas de `tabs` en el ledger de deuda +> RETIRADAS (acta en la cabecera de `scripts/theming-census-debt.ts`). Ninguna +> clave se acuñó: subió el alcance porque el nombre dejó de mentir. El detalle, +> en el veredicto §5. + + + ### 1.1 Directo a primitivo global (2) | # | fichero:línea | selector | propiedad | valor | @@ -135,4 +151,28 @@ muerta que ceder. **Su techo es 97 % y no es deuda**: lo que queda son las dos lecturas del canal de valor, que ningún tema debe alcanzar. +## Acta 2026-08-26 — el techo era 100 %, y el 3 % era el NOMBRE + +El veredicto de arriba acertó en la doctrina y se quedó corto en la aritmética. +«Las dos lecturas del canal de valor» eran, en efecto, canal — pero el censo no +podía verlo, y no por un defecto del censo: las cuatro vars se llamaban +`--indicator-{x,y,w,h}`, **sin prefijo de componente**. Un nombre así es público +de FORMA y de nadie de HECHO: la ley del espacio cerrado +(`recipe-css-contract.test.ts`) sólo audita `--{c}-*`, así que le pasaba por +debajo; y la regla de canal del censo exige el prefijo PROPIO +(`--_{c}-…`), así que sólo le quedaba puntuarlas `global` — deuda. `tabs` era +invisible justamente por nombrar PEOR que `navigation-menu`, que fue visible por +llevar prefijo. El guard premiaba al que nombraba mejor. + +La firma §14 lo cerró por el escritor: `MeasuredIndicator` recibe el kebab del +anfitrión (`tabsMorfo.kebab`, no un literal) y publica +`--_tabs-indicator-{x,y,w,h}`. La capa es la PLUMA, no la dueña — el rect es un +hecho de cada instancia del anfitrión. + +**Medido**: alcance **97 % → 100 %**, global 2 → 0, canal 0 → 2, knobs 77 +intactos, contrato 79 intacto (no se acuñó ni se retiró una clave). En el +catálogo, `atHundred` 65 → 66 y la deuda 1154 → 1152 claves. **§4.1 sigue siendo +incorrecta** y ahora también es inejecutable: el nombre que proponía tokenizar +ya no existe. + diff --git a/docs/audit/theming/tooltip.md b/docs/audit/theming/tooltip.md index cb313219b..e9c017933 100644 --- a/docs/audit/theming/tooltip.md +++ b/docs/audit/theming/tooltip.md @@ -50,6 +50,35 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2). ## 2-quater. Canal de valor (3) — fuera del ratio + + +> **Acta 2026-08-26 — un knob cruzó de «excepción firmada» a «canal», por +> MEDIDA.** `tooltip.css:27` (`inline-size: var(--_tooltip-width)`) aparece +> arriba en §1.4 porque hasta hoy sólo lo salvaba la firma a mano de la +> declaración; la firma §14 extendió la regla de canal del censo al caso +> TRANSITIVO —un privado declarado cuyas fuentes resuelven TODAS a canales ES +> canal— y `--_tooltip-width` es exactamente eso: +> `var(--_tooltip-content-width-override, var(--_tooltip-match-anchor-width, +> auto))`, dos nombres que nadie declara y que se escriben por instancia. El +> propio comentario de la declaración ya lo decía con esas palabras («per-instance +> VALUE CHANNEL»); lo que faltaba era el mecanismo, no el criterio. Por eso NO es +> una reclasificación a la baja: es el cuarto paso del censo funcionando como +> está escrito («una firma sobre un knob que la máquina ya adjudicó es un acta sin +> nada que firmar»). Al regenerar, esta hoja leerá canal 4 · excepción 1 · knobs +> 21 (el `exception` no entra en el total de knobs y el `channel` sí; el ALCANCE +> no se mueve: 94 %, los dos están fuera del ratio). +> +> Lo que la extensión **no** se lleva por delante, y es la mutación de rigor que +> la delimita: `--_tooltip-max-width` lee +> `var(--_tooltip-content-max-width-override, var(--tooltip-max-width))` y su +> knob sigue siendo **público**, porque el segundo paso del censo (deriva de un +> público) corre ANTES que el tercero. Ese orden es carga estructural de la +> extensión, no casualidad. Un alias sobre un privado que SÍ está declarado +> tampoco cuela: resuelve al valor de esa declaración, cuyas refs son vacías o no +> son canal. + + + La receta lee un privado que **nadie declara** — ni su CSS ni el generador: lo escribe soma o el envoltorio **por instancia** (un %, un rect medido, la talla que pide una prop). Un tema no debe alcanzarlo: fijarlo rompe el diff --git a/docs/next-features.md b/docs/next-features.md index dd56bc51b..16598740e 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -2559,7 +2559,12 @@ estorben. ## 14. Unificar `tabs` bajo la adjudicación del canal de valor — 2026-08-26 -**PENDIENTE — redactado, NO ejecutado.** Lo abre la firma «(a) completada» de +**✅ EJECUTADA 2026-08-26** — firma «MeasuredIndicator con nombre poseído», la +**tercera** aplicación de la doctrina del canal (`navigation-menu` fue la +segunda). El acta, con los números medidos, cierra esta sección; lo redactado +abajo se conserva porque el diagnóstico fue correcto entero. + +Lo abre la firma «(a) completada» de `navigation-menu` (§13 arriba y el punto 1 del bloque del registro): un indicador de `tablist` es **canal de valor, no superficie de tema**, y `tabs` citó a `navigation-menu` como precedente cuando firmó eso por escrito. Ahora el @@ -2606,3 +2611,102 @@ en estas partes, así que una lectura inmediata devuelve el valor VIEJO — hay congelar transiciones o el probe informa «no se movió» de todo. **Deps**: ninguna. Ejecutable cuando se quiera cerrar la clase. + +### 14-bis. Acta de ejecución — 2026-08-26 + +**Lo decidido en el punto 1**: `--_{c}-indicator-{x,y,w,h}`, **no** +`--_indicator-*` de capa. Porque el rect es un hecho de CADA INSTANCIA del +anfitrión: la capa sostiene la pluma, no es la dueña. Un solo nombre de capa +para dos anfitriones habría vuelto a poner una medida por instancia en un +espacio que nadie posee — el mismo defecto, un piso más abajo. + +**El paso del kebab, mínimo**: `MeasuredIndicatorOpts` gana **un** campo, +`component: string`, y los dos hosts pasan `tabsMorfo.kebab` / +`radioGroupMorfo.kebab` — **nunca un literal**. El nombre del canal queda atado +a la declaración de morfo que lo posee, así que un renombrado de kebab viaja +solo. Nada más se movió en la capa. + +**El cableado del CSS COMPARTIDO** (`lib/sliding-indicator.css`, que no puede +deletrear el nombre del anfitrión): cada receta declara su alias local explícito +—`--_indicator-x: var(--_radio-group-indicator-x)`— y el fichero compartido lee +el alias. Es la forma del puente THM-2 y la que `calendar-surface.css` ya usa +para `--_calendar-surface-accent`: contrato EN el artefacto, greppable en un +paso. `tabs` no consume la capa (tiene su bloque propio), así que lee el nombre +poseído directo. + +**Hosts, barridos**: exactamente DOS (`git grep "new MeasuredIndicator"`), los +dos ya redactados arriba. El barrido de retirada confirmó la trampa de las +llaves otra vez: el grep del nombre LITERAL veía 8 menciones, y el de la forma +`--indicator-{…}` más el genérico `--indicator-` destapó **tres comentarios de +código vivo más** (`radio-group.svelte:21`, `morfo/components/radio-group.ts:140`, +`tabs-provider.svelte.ts:423`). Consumos vivos de los cuatro nombres viejos tras +el flip: **0**. + +**El punto 2 se respetó**: `--indicator-duration` / `--indicator-ease` siguen +bare y sin tocar — son ranuras de FORMA de la capa (un tema las mueve), no +canal. El `_` es lo que separa las dos especies dentro del mismo fichero, y así +queda escrito en su cabecera. Su puerta sigue siendo la del contrato. + +**El punto 3, medido**: el suelo **sube**. `tabs` 97 % → **100 %** (global +2 → 0, canal 0 → 2, 77 knobs y 79 claves de contrato intactos: no se acuñó ni se +retiró una sola). Catálogo: `atHundred` **65 → 66**, global 763 → **761**, canal +38 → **41**, reach global **73 % intacto**, `--names` **4566 · 0 desviadas** +(intacto: `base.ts` no se tocó). Ledger de deuda: las **2 entradas de `tabs` +RETIRADAS** —la clave entera desaparece— con acta en la cabecera de +`scripts/theming-census-debt.ts`; **1154 → 1152 · 0 NEW · 0 STALE**. + +**La trampa de clasificación, prevista y resuelta**: la regla de canal del censo +mira el privado que el knob lee DIRECTAMENTE con cero declaraciones; una +línea-alias es un privado DECLARADO cuya única fuente es el canal, así que la +esquiva y cae a residuo `private` no-derivado. El tercer paso de `scanComponent` +resuelve ahora el canal **TRANSITIVAMENTE** (molde de `derivesFromPublic`: +memoizado, a prueba de ciclos, `every` en todo el camino). La **mutación de +rigor** que lo delimita: un alias hacia un privado que SÍ está declarado NO +clasifica canal — resuelve al valor de esa declaración, cuyas refs son vacías (un +literal) o no son canal; `refs.length > 0` es la línea que lo impone. Y el orden +de pasos es carga estructural: el segundo (deriva de un público) corre ANTES, así +que `--_tooltip-max-width`, que lee `var(--_tooltip-content-max-width-override, +var(--tooltip-max-width))`, sigue siendo **público**. + +**Un efecto colateral, medido y adjudicado con acta**: la extensión reclasificó +**un** knob ajeno, `tooltip.css:27` (`inline-size: var(--_tooltip-width)`), de +`exception` a `channel`. **No es regresión, es validación independiente**: el +comentario firmado de esa declaración ya decía «per-instance VALUE CHANNEL» con +esas palabras, y `--_tooltip-width` resuelve a dos nombres que nadie declara. +Faltaba el mecanismo, no el criterio — que es exactamente lo que el cuarto paso +del censo dice de sí mismo («una firma sobre un knob que la máquina ya adjudicó +es un acta sin nada que firmar»). `tooltip` no mueve su alcance (94 %: las dos +clases están fuera del ratio). Acta en `docs/audit/theming/tooltip.md` §2-quater. + +**Verificación**. Dinámica por CDP con transiciones CONGELADAS (el aviso de esta +misma sección: `inline-size` va transicionada y una lectura inmediata devuelve el +valor VIEJO), en los **dos** hosts. `tabs`: el estilo inline lleva sólo los +nombres poseídos, los cuatro viejos computan **cadena vacía**, y al cambiar de +pestaña `translate 0px → 183.719px` e `inline-size 99.906px → 71.359px` — el +indicador cae EXACTO sobre el trigger activo (x 784 vs 784, w 71 vs 71). Control +negativo: escribir los cuatro nombres viejos en `:root` no mueve **nada**. +Control positivo: `--_tabs-indicator-w` en el elemento → 333px. `radio-group` +manifiesta su indicador **sólo en la variante `segmented`** (la píldora +`[data-radio-group-selection-indicator]`, que el envoltorio auto-monta ahí y en +ningún otro sitio): el alias computa los mismos cuatro valores que el nombre +poseído, la píldora desliza (`translate 4px 32px → 4px 60px`) y cubre exacto el +item marcado (x 793 vs 793, w 102 vs 102); mismo control negativo a cero y el +positivo probando la CADENA del alias (101.703px → 222px a través del CSS +compartido). + +Estática: sonda estándar de `tabs` antes/después, **2528 valores computados en 10 +estados, 0 diffs y 0 nodos perdidos**. ⚠ Y hay que decirlo: **esa sonda es CIEGA +al canal** — lee valores computados, no nombres de var, así que un flip de nombre +correcto le sale idéntico por construcción. Su 0 no prueba el flip; prueba lo +CIRCUNDANTE, que es para lo que se corre. Quien prueba el flip es la dinámica. + +Verde lo demás: `recipe-css-contract` + `rtl-lint` + `theming-reach-floor` + +`component-visual-attrs` + `generated-css` + las suites de los tocados (10 +ficheros, 154 tests), centinela de `tabs` **70/79** idéntico, `component:audit` +**162 PASS / 4 NEEDS-WORK / 0 BROKEN** idéntico, `docs:check` **0/0** en 816 +docs, `npm run check` sin errores atribuibles. + +**Lo que queda flagged, sin acuñar**: `--indicator-duration` / `--indicator-ease` +siguen siendo dos públicos de FORMA sin prefijo de dueño y sin entrada de +contrato — la misma fuga de espacio que esta firma cerró para las medidas, una +especie más allá. El punto 2 los mandó a la puerta del contrato y ahí siguen. diff --git a/docs/process/CONTINUE-direction.md b/docs/process/CONTINUE-direction.md index c552b2c87..62f008c24 100644 --- a/docs/process/CONTINUE-direction.md +++ b/docs/process/CONTINUE-direction.md @@ -342,6 +342,12 @@ temprano**, para que la dependencia se registre en todas las pasadas. El consumi (`radio-group`) le pasa `resolvedDir`. Getter y no lectura del DOM: la dirección viene de la cadena prop/prefs, y el atributo es proyección, no fuente. +> **Nota 2026-08-26** — las cuatro medidas que aquí se llaman `--indicator-*` se +> renombraron al namespace del anfitrión (`--_{c}-indicator-{x,y,w,h}`) por la +> firma §14 del eje theming. Nada de lo medido en esta sección cambia: sólo el +> nombre. Detalle en `CONTINUE-theming.md`, «§14, MeasuredIndicator con nombre +> POSEÍDO». + ⚠️ **TRAMPA DE MÉTODO, me costó una pasada entera**: el primer probe volteaba con `el.setAttribute('dir', 'rtl')` y daba «no arreglado» — pero eso **no cambia `resolvedDir`**, que sale de la prop/prefs, así que la dependencia no podía diff --git a/docs/process/CONTINUE-theming.md b/docs/process/CONTINUE-theming.md index 9c47fa846..3072884ab 100644 --- a/docs/process/CONTINUE-theming.md +++ b/docs/process/CONTINUE-theming.md @@ -189,8 +189,67 @@ en §13 de `next-features.md`, sin inventar un eje de forma. **Abre expediente**: §14 de `next-features.md` — unificar `tabs` bajo la misma adjudicación (`--indicator-{x,y,w,h}` SIN prefijo, escritos por la capa -compartida `MeasuredIndicator`, 2 entradas `global` en el ledger). Redactado, -**NO ejecutado**. +compartida `MeasuredIndicator`, 2 entradas `global` en el ledger). **EJECUTADA +el mismo día**; ver la firma inmediatamente debajo. + +### FIRMA EJECUTADA (2026-08-26) — §14, MeasuredIndicator con nombre POSEÍDO + +**Qué se firmó**: la geometría que una capa COMPARTIDA publica es **canal** y +aterriza en el **namespace del ANFITRIÓN** —`--_{c}-indicator-{x,y,w,h}`—, no en +uno de capa. Tercera aplicación de la doctrina (`navigation-menu` fue la +segunda). **La capa es la PLUMA, no la dueña**: el rect es un hecho de cada +instancia del host. Un solo nombre de capa habría repetido el defecto un piso +más abajo — una medida por instancia en un espacio que nadie posee. + +**Cómo llega el nombre**: `MeasuredIndicatorOpts` gana **un** campo, +`component: string`, y los dos hosts —los únicos, `git grep "new +MeasuredIndicator"`— pasan `tabsMorfo.kebab` / `radioGroupMorfo.kebab`, **nunca +un literal**: el canal queda atado a la declaración de morfo que lo posee. + +**Cableado del CSS compartido** (`lib/sliding-indicator.css`, que no puede +deletrear el nombre del host): cada receta declara su alias local explícito +—`--_indicator-x: var(--_radio-group-indicator-x)`— y el compartido lee el +alias. Forma del puente THM-2, la misma que `calendar-surface.css` usa para +`--_calendar-surface-accent`: contrato EN el artefacto. `tabs` no consume la capa +(tiene bloque propio) y lee el nombre poseído directo. + +**Números**: `tabs` **97 % → 100 %** (global 2 → 0, canal 0 → 2; 77 knobs y 79 +claves de contrato INTACTOS — ni una acuñada ni retirada). Catálogo `atHundred` +**65 → 66**, global 763 → 761, canal 38 → 41, reach global **73 % intacto**, +`--names` 4566 · 0 desviadas. Ledger: las **2 entradas de `tabs` retiradas** +(la clave entera desaparece), **1154 → 1152 · 0 NEW · 0 STALE**, con acta en la +cabecera de `theming-census-debt.ts`. `radio-group` no mueve una cifra. + +**⚠ La trampa de clasificación, prevista y resuelta**: la regla de canal miraba +el privado que el knob lee DIRECTAMENTE con cero declaraciones — una línea-alias +es un privado DECLARADO cuya única fuente es el canal, así que la esquivaba y +caía a residuo `private` no-derivado, `newDebt` ROJO con la razón equivocada. El +tercer paso del censo resuelve ahora el canal **TRANSITIVAMENTE** (molde de +`derivesFromPublic`). **Mutación de rigor**: un alias hacia un privado que SÍ +está declarado NO clasifica canal — `refs.length > 0` es la línea que lo impone. +Y el ORDEN de pasos es carga estructural: el segundo (deriva de un público) corre +antes, y por eso `--_tooltip-max-width` sigue siendo público. + +**⚠ Un knob AJENO se reclasificó, con acta**: `tooltip.css:27` pasó de +`exception` a `channel`. **No es regresión**: el comentario firmado de esa +declaración ya decía «per-instance VALUE CHANNEL» con esas palabras — faltaba el +mecanismo, no el criterio, que es literalmente lo que el cuarto paso del censo +dice de sí mismo. `tooltip` no mueve su alcance. Acta en su ficha, §2-quater. + +**⚠ La sonda estándar es CIEGA a esta clase de firma**: lee valores computados, +no nombres de var, así que un flip de nombre correcto le sale idéntico por +construcción. Sus **0 diffs en 2528 valores × 10 estados** prueban lo +CIRCUNDANTE, no el flip. Quien prueba el flip es la dinámica, y hay que +CONGELAR transiciones o `inline-size` devuelve el valor viejo: `tabs` desliza +(`translate 0 → 183.719px`, `inline-size 99.906 → 71.359px`, indicador exacto +sobre el trigger activo), `radio-group` sólo manifiesta su indicador en la +variante **`segmented`** y desliza igual (`4px 32px → 4px 60px`). Los cuatro +nombres viejos computan **cadena vacía** en ambos; control negativo en `:root` a +cero; control positivo probando además la CADENA del alias. + +**Hueco FLAGGED, no acuñado**: `--indicator-duration` / `--indicator-ease` siguen +bare, sin dueño y sin entrada de contrato — la misma fuga de espacio, una especie +más allá (son ranuras de FORMA, no canal). Su puerta es el contrato, no ésta. ### FIRMA EJECUTADA (2026-08-26) — la ley del espacio cerrado @@ -982,9 +1041,12 @@ eso ES el resultado. - **`tabs`** — la mitad de un contrato puede vivir en las VARIANTES (40/79 → 70/79 barriendo `data-variant` y usando las dos URL que el propio demo - ofrece), y su indicador es **canal de valor**: `--indicator-{x,y,w,h}` los - escribe `MeasuredIndicator` en cada movimiento, así que un público encima + ofrece), y su indicador es **canal de valor**: `--_tabs-indicator-{x,y,w,h}` + los escribe `MeasuredIndicator` en cada movimiento, así que un público encima sería un knob que un tema puede fijar — y fijarlo rompe el deslizamiento. + ⚠ **Actualizado 2026-08-26 (§14)**: entonces se llamaban `--indicator-*`, sin + prefijo, y por eso el censo sólo podía puntuarlos `global` — el techo de + `tabs` NO era 97 %, era **100 %**, y el 3 % que faltaba era el NOMBRE. - **`toast`** — **§12.9 en una tarjeta PORTALADA se lleva la TARJETA ENTERA**, 21 claves, y con coste de PRODUCTO: un toast `risk` y uno `fulfill` visten la misma tarjeta gris, y la franja de acento no se ve nunca en reposo (el plano diff --git a/docs/process/PLAN-theming.md b/docs/process/PLAN-theming.md index 30d485d67..577ce1e58 100644 --- a/docs/process/PLAN-theming.md +++ b/docs/process/PLAN-theming.md @@ -842,7 +842,13 @@ excepción abre la mano para MEDIR, no para contabilizar. escribe el rect del trigger activo en cada movimiento, así que un público encima sería un knob que un tema puede FIJAR, y fijarlo rompe el deslizamiento. Es la adjudicación que `navigation-menu` ya lleva escrita para - su propio `indicator-w` (y la razón por la que su `-x` se retiró, muerto). El + su propio `indicator-w` (y la razón por la que su `-x` se retiró, muerto). + ⚠ **Anotado 2026-08-26 (firma §14)**: la adjudicación era correcta y el + NÚMERO se quedó corto. Aquellas vars se llamaban `--indicator-*` **sin prefijo + de componente** —públicas de forma, de nadie de hecho—, así que el censo sólo + podía puntuarlas `global` y la ley del espacio cerrado no las veía. Movidas al + namespace del anfitrión (`--_tabs-indicator-*`) leen como el canal que son: + `tabs` **97 % → 100 %**, sin acuñar una sola clave. El tercero, el `width: 100%` de `fitted`, se FIRMA como identidad. **Lo que enseñó**: (1) **la mitad de un contrato puede vivir en las VARIANTES** — el escenario arranca en `line` y el guard leía 40/79; barriendo `data-variant` y diff --git a/scripts/theming-census-debt.ts b/scripts/theming-census-debt.ts index bd58d91eb..bae04c789 100644 --- a/scripts/theming-census-debt.ts +++ b/scripts/theming-census-debt.ts @@ -109,6 +109,24 @@ * design: `system`, `structural`, `bridge`, `channel` and `exception` — four * measured classes and one written act, each documented where it is decided. * + * ── ACTA 2026-08-26 — the whole `tabs` key RETIRED, two entries ───────────── + * `global · tabs.css · [data-tabs-indicator] · {block-size, inline-size}` were + * the last two, and they left by exit 1 (TOKENIZED) read strictly, by exit 4 + * (SYSTEM) read honestly: the declarations did not change shape, the NAME they + * read did. Firma §14 («MeasuredIndicator con nombre poseído»): the shared soma + * layer used to publish the measured rect as a bare `--indicator-{x,y,w,h}` — + * PUBLIC in shape and owned by nobody, which is why the census could only score + * it `global` and why the closed-namespace law never saw it (that law audits + * `--{c}-*`, and a name with no component prefix is foreign vocabulary). The + * layer now writes into the HOST's namespace, `--_tabs-indicator-*`, so the two + * knobs read as what they always were: the value CHANNEL, out of the ratio. + * Measured: `tabs` global 2 → 0, channel 0 → 2, reach 97 % → 100 %, knobs 77 + * unchanged; catalogue `atHundred` 65 → 66. No key was tokenized away and no + * debt was forgiven — a misclassification was corrected at its source, which is + * exactly the reading the header's «renaming a selector reads as one STALE + one + * new key» paragraph anticipates, except here the new key belongs to a class + * this ledger does not register. + * * Generated: `node --import tsx/esm scripts/theming-census.ts --debt --write` * Checked: `node --import tsx/esm scripts/theming-census.ts --debt` * Guarded: `src/uix/eidos/theming-reach-floor.test.ts` (catalogue) + @@ -1238,10 +1256,6 @@ export const CENSUS_DEBT: Record = { 'literal · table.css · [data-table-sort-trigger][data-sorted] [data-table-sort-indicator] · opacity', 'literal · table.css · [data-table] · inline-size' ], - tabs: [ - 'global · tabs.css · [data-tabs-indicator] · block-size', - 'global · tabs.css · [data-tabs-indicator] · inline-size' - ], 'tag-group': ['literal · tag-group.css · [data-tag-group-remove-button] · line-height'], 'text-scramble': [ 'literal · text-scramble.css · [data-text-scramble-sr] · height', diff --git a/scripts/theming-census.ts b/scripts/theming-census.ts index 95780d290..5500239cf 100644 --- a/scripts/theming-census.ts +++ b/scripts/theming-census.ts @@ -652,13 +652,47 @@ export function scanComponent(dir: string): Scan | null { // the same mechanism wherever it is emitted — but never a channel: this // component's CSS is not where it would be declared. const own = '--_' + dir + '-'; + const isChannel = (r: string) => + r.startsWith(own) && !declBySource.has(r) && !GENERATED_PRIVATES.has(r); + // TRANSITIVE, and this is the shape that forced it (firma §14, 2026-08-26): a + // SHARED layer's CSS cannot spell its host's name, so the consuming recipe + // declares a local ALIAS over the channel and the layer reads the alias — the + // palette bridge's wiring, one class over. That alias is a DECLARED private + // whose only source is an UNDECLARED one, so the direct test above misses it + // by construction and the knob falls to residue as a non-derived private: + // `newDebt` red with the wrong reason. Same mould as `derivesFromPublic` + // (cycle-safe via `seen`, memoized), and `every` throughout, so a knob mixing + // a channel with anything else stays residue. + // + // The rigour that keeps this from swallowing the class: an alias over a + // private that IS declared here resolves to THAT declaration's value, whose + // refs are either empty (a literal) or point somewhere that is not a channel + // — `refs.length > 0` is what makes the literal case return false. Only a + // chain that bottoms out in a name NOBODY declares reads as channel. + const channelCache = new Map(); + const resolvesToChannel = (name: string, seen = new Set()): boolean => { + if (isChannel(name)) return true; + if (channelCache.has(name)) return channelCache.get(name)!; + if (seen.has(name)) return false; + seen.add(name); + const ok = (privValues.get(name) ?? []).every((v) => { + const refs = [...v.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]); + return refs.length > 0 && refs.every((r) => resolvesToChannel(r, seen)); + }); + // `every` on an empty list is `true` — a name this component never declares + // is not an alias, it is a foreign private, and the guard above already + // refused it as a channel. + const declared = (privValues.get(name) ?? []).length > 0; + channelCache.set(name, ok && declared); + return ok && declared; + }; for (const k of knobs) { if (k.klass !== 'private') continue; const refs = [...k.value.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]); if (refs.length === 0) continue; const klass: KnobClass | null = refs.every((r) => PALETTE_BRIDGE.has(r)) ? 'bridge' - : refs.every((r) => r.startsWith(own) && !declBySource.has(r) && !GENERATED_PRIVATES.has(r)) + : refs.every((r) => resolvesToChannel(r)) ? 'channel' : null; if (klass === null) continue; diff --git a/src/uix/eidos/components/radio-group/radio-group.css b/src/uix/eidos/components/radio-group/radio-group.css index d9c82da20..b896b993e 100644 --- a/src/uix/eidos/components/radio-group/radio-group.css +++ b/src/uix/eidos/components/radio-group/radio-group.css @@ -219,8 +219,9 @@ * The options sit as flush text SEGMENTS in a recessed track; a neutral pill * SLIDES to the checked one (iOS UISegmentedControl / Radix SegmentedControl / * Ark SegmentGroup). Soma measures the checked item (MeasuredIndicator) and - * writes `--indicator-*` on the pill; `lib/sliding-indicator.css` positions it, - * this recipe SHAPES it. The per-item dot is dropped; role stays `radiogroup`. + * writes `--_radio-group-indicator-*` on the pill; `lib/sliding-indicator.css` + * positions it through the `--_indicator-*` alias declared below, and this + * recipe SHAPES it. The per-item dot is dropped; role stays `radiogroup`. * In this variant the item BEARS its text (children) — unlike the circle * variants, where the label sits external. */ @@ -294,7 +295,7 @@ } /* The sliding pill — SHAPE only; `lib/sliding-indicator.css` (opted in via - * `data-sliding-indicator`) owns position + size + slide from `--indicator-*`. + * `data-sliding-indicator`) owns position + size + slide from `--_indicator-*`. * Its timing overrides feed the shared transition. */ /* The pill IS the selection marker → it takes the component's `color` (like the * dot in the other variants), via the shared palette private. A coloured pill on @@ -305,6 +306,17 @@ background: var(--_radio-group-palette-solid); border-radius: var(--radio-group-segmented-pill-radius); box-shadow: var(--radio-group-segmented-pill-shadow); + /* The shared layer's ALIAS over soma's value channel (firma §14, 2026-08-26). + * `MeasuredIndicator` writes the rect in THIS component's namespace, because + * the rect is a fact of this instance; a shared file cannot spell that name, + * so it reads `--_indicator-*` and the consumer wires it here — the palette + * bridge's shape, contract in the artifact. NOT a theming surface: pinning + * one of the four from a theme freezes the pill. */ + --_indicator-x: var(--_radio-group-indicator-x); + --_indicator-y: var(--_radio-group-indicator-y); + --_indicator-w: var(--_radio-group-indicator-w); + --_indicator-h: var(--_radio-group-indicator-h); + /* Shape slots of the layer, a different species — themeable, so bare. */ --indicator-duration: var(--radio-group-segmented-transition-duration); --indicator-ease: var(--radio-group-segmented-transition-ease); } diff --git a/src/uix/eidos/components/radio-group/radio-group.svelte b/src/uix/eidos/components/radio-group/radio-group.svelte index 4189401c0..675e71016 100644 --- a/src/uix/eidos/components/radio-group/radio-group.svelte +++ b/src/uix/eidos/components/radio-group/radio-group.svelte @@ -18,7 +18,8 @@ import { composeInlineStyle } from '$uix/eidos/lib/style'; import { resolveComponentColor } from '$uix/eidos/lib/component-color'; // Shared sliding-indicator positioning — consumed by the `segmented` variant's - // pill (soma measures + writes `--indicator-*`; this file shapes it). + // pill (soma measures + writes `--_radio-group-indicator-*`, which the recipe + // aliases to the layer's `--_indicator-*`; this file shapes it). import '$uix/eidos/lib/sliding-indicator.css'; import * as RadioGroup from '$soma/components/radio-group'; import type { RadioGroupProps } from './types'; diff --git a/src/uix/eidos/components/tabs/README.md b/src/uix/eidos/components/tabs/README.md index fd6461715..567bd4cd3 100644 --- a/src/uix/eidos/components/tabs/README.md +++ b/src/uix/eidos/components/tabs/README.md @@ -69,7 +69,8 @@ Sources: [Radix](https://www.radix-ui.com/primitives/docs/components/tabs), relationships. - Soma owns value, keyboard navigation, activation mode, trigger/content ID registries, disabled handling, presence and the indicator's MEASUREMENT - (`MeasuredIndicator`, exposed as `--indicator-{x,y,w,h}`). + (`MeasuredIndicator`, exposed as `--_tabs-indicator-{x,y,w,h}` — the layer is + shared, the NAME is this component's; firma §14, 2026-08-26). - Eidos owns size/variant/collapse/scroll/motion presentation, and positions + shapes the indicator from those vars — the "soma positions, eidos shapes" contract. The measurement used to live in the eidos wrapper; it moved to soma @@ -95,11 +96,20 @@ demo las resuelve en vivo sobre el escenario. El reparto, por dónde pintan: Lo que el eje dejó **fuera** el 2026-08-24 (96 % → 97 %), medido y escrito: -- **`--indicator-{x,y,w,h}` NO se acuñan.** Son el canal de VALOR de soma: la - capa `MeasuredIndicator` escribe el rect del trigger activo en cada +- **`--_tabs-indicator-{x,y,w,h}` NO se acuñan.** Son el canal de VALOR de soma: + la capa `MeasuredIndicator` escribe el rect del trigger activo en cada movimiento. Un público encima sería un knob que un tema podría fijar, y fijarlo rompe el deslizamiento — la misma adjudicación que `navigation-menu` ya lleva para su propio `indicator-w`. + + Hasta el **2026-08-26** las cuatro se llamaban `--indicator-{x,y,w,h}`, **sin + prefijo de componente**: públicas de FORMA y de nadie. Por eso el censo sólo + podía puntuarlas `global` (2 entradas del ledger de deuda) y la ley del + espacio cerrado no las veía —esa ley audita `--{c}-*`, y un nombre sin prefijo + es vocabulario ajeno—. La firma §14 hizo que la capa compartida escriba en el + espacio del ANFITRIÓN: `tabs` pasó de **97 % a 100 %** (global 2 → 0, canal + 0 → 2, 77 knobs iguales) sin acuñar una sola clave. El alcance no subió porque + se tokenizara nada: subió porque el nombre dejó de mentir. - **`width: 100%` de `fitted` es identidad**, no knob: `fitted` ES «la lista llena el eje inline». Firmado con su anotación `literal:`. diff --git a/src/uix/eidos/components/tabs/tabs-indicator.svelte b/src/uix/eidos/components/tabs/tabs-indicator.svelte index 4d55c8f9b..09f818ee8 100644 --- a/src/uix/eidos/components/tabs/tabs-indicator.svelte +++ b/src/uix/eidos/components/tabs/tabs-indicator.svelte @@ -4,7 +4,8 @@ * slides between active triggers. * * Shape only. Soma measures the active trigger and hands down - * `--indicator-{x,y,w,h}` (the shared `MeasuredIndicator` layer); the recipe + * `--_tabs-indicator-{x,y,w,h}` (the shared `MeasuredIndicator` layer, which + * writes into the HOST's namespace — firma §14, 2026-08-26); the recipe * positions and sizes from those vars and owns the slide transition. This * wrapper used to do the measuring itself with its own rAF + observers — * that duplicated the layer and, having no access to the resolved direction, diff --git a/src/uix/eidos/components/tabs/tabs.css b/src/uix/eidos/components/tabs/tabs.css index cc3c97883..7f8e301cf 100644 --- a/src/uix/eidos/components/tabs/tabs.css +++ b/src/uix/eidos/components/tabs/tabs.css @@ -283,10 +283,10 @@ * active trigger's offset rect. The recipe handles the slide animation, * z-index, and per-variant appearance. */ -/* Position + size come from soma's `--indicator-*` (the "soma positions, eidos - shapes" contract); the variants below own the look and may pin a cross-axis - dimension on top. Uses the independent `translate` property, not `transform`, - so a per-variant `transform` never wipes the slide. +/* Position + size come from soma's `--_tabs-indicator-*` (the "soma positions, + eidos shapes" contract); the variants below own the look and may pin a + cross-axis dimension on top. Uses the independent `translate` property, not + `transform`, so a per-variant `transform` never wipes the slide. Those four vars are soma's VALUE CHANNEL, not a theming surface: the `MeasuredIndicator` layer writes the active trigger's offset rect onto this @@ -297,6 +297,12 @@ thickness, fill, radius, shadow, the slide's duration and ease — and those are the keys below. + They are PRIVATE and they carry the host's name (firma §14, 2026-08-26). + Until then the layer wrote a bare `--indicator-*`: public in SHAPE, owned by + nobody, and therefore invisible to the closed-namespace law, which only + audits `--{c}-*`. Spelling them `--_tabs-*` is what makes the census read + them as the channel they are. + `left` is PHYSICAL on purpose: soma measures `triggerRect.left - listRect.left`, an offset that grows rightwards whatever the direction. A logical `inset-inline-start` anchored the indicator to the RIGHT edge in RTL while the @@ -309,9 +315,9 @@ left: 0; pointer-events: none; z-index: 0; - translate: var(--indicator-x, 0px) var(--indicator-y, 0px); - inline-size: var(--indicator-w, 0px); - block-size: var(--indicator-h, 0px); + translate: var(--_tabs-indicator-x, 0px) var(--_tabs-indicator-y, 0px); + inline-size: var(--_tabs-indicator-w, 0px); + block-size: var(--_tabs-indicator-h, 0px); transition: translate var(--tabs-indicator-transition-duration) var(--tabs-indicator-transition-ease), inline-size var(--tabs-indicator-transition-duration) var(--tabs-indicator-transition-ease), diff --git a/src/uix/eidos/lib/sliding-indicator.css b/src/uix/eidos/lib/sliding-indicator.css index 036ced17d..e44622766 100644 --- a/src/uix/eidos/lib/sliding-indicator.css +++ b/src/uix/eidos/lib/sliding-indicator.css @@ -4,9 +4,27 @@ * * The soma `MeasuredIndicator` layer (`soma/layers/measured-indicator`) writes * the active item's offset rect onto the indicator element as - * `--indicator-{x,y,w,h}`. This file POSITIONS + sizes the indicator from those - * vars and animates the slide; each component's recipe SHAPES it on top - * (pill background, underline thickness, border-radius, box-shadow). + * `--_{component}-indicator-{x,y,w,h}` — the HOST's namespace, because the rect + * is a fact of the host's instance (firma §14, 2026-08-26; the bare + * `--indicator-*` it wrote before was public in SHAPE and owned by nobody). + * + * A SHARED file cannot spell the host's name, so it reads a local ALIAS and each + * consuming recipe declares it — the palette bridge's wiring, and the same shape + * `calendar-surface.css` uses for `--_calendar-surface-accent`: + * + * [data-radio-group-selection-indicator] { + * --_indicator-x: var(--_radio-group-indicator-x); + * } + * + * The contract lives IN the artifact and greps in one step. This file POSITIONS + * + sizes the indicator from those vars and animates the slide; each component's + * recipe SHAPES it on top (pill background, underline thickness, border-radius, + * box-shadow). + * + * ⚠ `--indicator-duration` / `--indicator-ease` below are a DIFFERENT species + * and keep their bare spelling on purpose: they are the layer's shape slots — a + * theming surface a recipe declares — not a per-instance channel. The `_` is + * what separates the two. * * The consumer's eidos wrapper STAMPS `data-sliding-indicator` on the indicator * element — a VISUAL-LAYER identity (NOT a morfo attr), the same pattern as @@ -29,9 +47,9 @@ the two are identical, so this changes nothing there. */ left: 0; pointer-events: none; - translate: var(--indicator-x, 0px) var(--indicator-y, 0px); - inline-size: var(--indicator-w, 0px); - block-size: var(--indicator-h, 0px); + translate: var(--_indicator-x, 0px) var(--_indicator-y, 0px); + inline-size: var(--_indicator-w, 0px); + block-size: var(--_indicator-h, 0px); transition: translate var(--indicator-duration, var(--duration-fast)) var(--indicator-ease, var(--ease-default)), diff --git a/src/uix/eidos/rtl-lint.test.ts b/src/uix/eidos/rtl-lint.test.ts index 5d35d8e48..aceffd71f 100644 --- a/src/uix/eidos/rtl-lint.test.ts +++ b/src/uix/eidos/rtl-lint.test.ts @@ -78,18 +78,20 @@ describe('lintRtlGeometry — RTL-1', () => { }); it('treats a var() X as displacing — the sign is unknown at author time', () => { - // `sliding-indicator.css`: soma writes `--indicator-x` from an offset rect. + // `sliding-indicator.css`: soma writes the offset rect into the HOST's + // namespace and the shared file reads it through the `--_indicator-*` + // alias each consuming recipe declares (firma §14, 2026-08-26). const css = ` [data-sliding-indicator] { inset-inline-start: 0; - translate: var(--indicator-x, 0px) var(--indicator-y, 0px); + translate: var(--_indicator-x, 0px) var(--_indicator-y, 0px); } `; const findings = lintRtlGeometry(css); expect(findings).toHaveLength(1); // The comma inside var() must not split the X component off. expect(findings[0].translate).toBe( - 'translate: var(--indicator-x, 0px) var(--indicator-y, 0px)' + 'translate: var(--_indicator-x, 0px) var(--_indicator-y, 0px)' ); }); diff --git a/src/uix/morfo/components/radio-group.ts b/src/uix/morfo/components/radio-group.ts index d50943881..ea1939327 100644 --- a/src/uix/morfo/components/radio-group.ts +++ b/src/uix/morfo/components/radio-group.ts @@ -137,7 +137,7 @@ export const radioGroupMorfo = { // a pill that slides to the checked item (the Ark `SegmentGroup` // indicator; distinct from the per-item `Indicator` dot above). // Decorative (aria-hidden); soma OWNS its position (measures the - // checked item, writes `--indicator-*`), eidos SHAPES it. Only + // checked item, writes `--_radio-group-indicator-*`), eidos SHAPES it. Only // meaningful under `[data-variant='segmented']`; the eidos wrapper // auto-renders it there, so the consumer never composes it by hand. name: 'SelectionIndicator', diff --git a/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts b/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts index edb9c7e2c..163ce70a4 100644 --- a/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts +++ b/src/uix/soma/components/radio-group/radio-group-provider.svelte.ts @@ -92,8 +92,7 @@ export class RadioGroupProvider { id: opts.id, ref: opts.ref, owner: this, - context: RadioGroupProvider.ctx, - syncAttrs: true + context: RadioGroupProvider.ctx }); } @@ -218,8 +217,7 @@ export class RadioItemProvider { orientation: () => this.provider.opts.orientation.current }, owner: this, - context: RadioItemProvider.ctx, - syncAttrs: true + context: RadioItemProvider.ctx }); } @@ -306,8 +304,7 @@ export class RadioGroupIndicatorProvider { states: { checked: () => this.item.isChecked }, - owner: this, - syncAttrs: true + owner: this }); } @@ -342,16 +339,17 @@ export class RadioGroupSelectionIndicatorProvider { this.runtimePart = this.provider.runtime.part('selection-indicator', { id: opts.id, ref: opts.ref, - owner: this, - syncAttrs: true + owner: this }); // Soma owns the pill's position (the "soma positions, eidos shapes" // contract): it tracks the checked item and exposes its offset rect as - // `--indicator-*`. The recipe positions + shapes the pill from those - // vars via the shared `lib/sliding-indicator.css`. + // `--_radio-group-indicator-*`. The recipe positions + shapes the pill + // from those vars via the shared `lib/sliding-indicator.css`, which reads + // them through the `--_indicator-*` alias the recipe declares. this.indicator = new MeasuredIndicator({ dom: this.provider.soma.dom, + component: radioGroupMorfo.kebab, root: () => this.provider.opts.ref.current ?? null, active: () => { const value = this.provider.opts.value.current; @@ -389,8 +387,7 @@ export class RadioGroupHiddenInputProvider { this.runtimePart = this.item.provider.runtime.part('hidden-input', { id: opts.id, ref: opts.ref, - owner: this, - syncAttrs: true + owner: this }); } diff --git a/src/uix/soma/components/tabs/tabs-provider.svelte.ts b/src/uix/soma/components/tabs/tabs-provider.svelte.ts index 000e0dee1..6180233fb 100644 --- a/src/uix/soma/components/tabs/tabs-provider.svelte.ts +++ b/src/uix/soma/components/tabs/tabs-provider.svelte.ts @@ -95,8 +95,7 @@ export class TabsProvider { id: opts.id, ref: opts.ref, owner: this, - context: TabsProvider.ctx, - syncAttrs: true + context: TabsProvider.ctx }); } @@ -204,8 +203,7 @@ export class TabsListProvider { ariaLabel: () => this.opts.ariaLabel.current, ariaLabelledby: () => this.opts.ariaLabelledby.current }, - owner: this, - syncAttrs: true + owner: this }); } @@ -251,8 +249,7 @@ export class TabsTriggerProvider { parts: { content: () => this.provider.contentIds[this.opts.value.current] || undefined }, - owner: this, - syncAttrs: true + owner: this }); this.provider.triggerIds[opts.value.current] = opts.id.current; @@ -376,8 +373,7 @@ export class TabsContentProvider { parts: { trigger: () => this.provider.triggerIds[this.opts.value.current] || undefined }, - owner: this, - syncAttrs: true + owner: this }); this.provider.contentIds[opts.value.current] = opts.id.current; @@ -420,7 +416,7 @@ interface TabsIndicatorOpts extends WithRefOpts {} * Indicator part — an absolutely-positioned element that slides between * active triggers, on the shared "soma positions, eidos shapes" contract: * soma measures the active trigger and exposes its offset rect as - * `--indicator-*`; the recipe positions + shapes from those vars. + * `--_tabs-indicator-*`; the recipe positions + shapes from those vars. * * The measurement used to live in the eidos wrapper with its own rAF and * observers. It moved here because that duplicated `MeasuredIndicator` — the @@ -447,12 +443,12 @@ export class TabsIndicatorProvider { props: { orientation: () => this.provider.opts.orientation.current }, - owner: this, - syncAttrs: true + owner: this }); this.indicator = new MeasuredIndicator({ dom: this.provider.soma.dom, + component: tabsMorfo.kebab, // The indicator parents itself to the List — that is the contract the // recipe already relies on for its absolute positioning. root: () => this.opts.ref.current?.parentElement ?? null, diff --git a/src/uix/soma/layers/measured-indicator.svelte.ts b/src/uix/soma/layers/measured-indicator.svelte.ts index 1445ced0c..b8285d8b8 100644 --- a/src/uix/soma/layers/measured-indicator.svelte.ts +++ b/src/uix/soma/layers/measured-indicator.svelte.ts @@ -17,6 +17,15 @@ export interface MeasuredIndicatorRect { export interface MeasuredIndicatorOpts { /** The scope's ActiveDom (`soma.dom`). */ dom: ActiveDom; + /** + * The HOST component's kebab — pass `xxxMorfo.kebab`, never a literal, so the + * channel's spelling follows the morfo declaration that owns it. + * + * The layer is the PEN, not the owner: the rect it publishes is a fact of the + * host's instance, so the custom properties land in the HOST's namespace + * (`--_{kebab}-indicator-*`). See {@link MeasuredIndicator} for why. + */ + component: string; /** The container the indicator positions within (list / group root). */ root: Getter; /** @@ -45,11 +54,22 @@ export interface MeasuredIndicatorOpts { * A sliding indicator's measurement engine — soma's half of the * "soma positions, eidos shapes" contract (navigation-menu README §Indicator). * - * Given the container and the active item, it exposes the item's offset rect - * as `--indicator-{x,y,w,h}` custom properties. The recipe POSITIONS + SHAPES - * from those vars (`translate: var(--indicator-x) var(--indicator-y)`, and - * `width`/`height` or a per-variant override) — the layer never inline-styles a - * base position, so there is no double-offset footgun. + * Given the container and the active item, it exposes the item's offset rect as + * `--_{component}-indicator-{x,y,w,h}` custom properties — the HOST's namespace, + * not the layer's. The rect is a fact of the host's instance; the layer only + * holds the pen. A bare `--indicator-*` (what this wrote until 2026-08-26) is a + * PUBLIC-shaped name owned by nobody: the closed-namespace law only audits + * `--{c}-*`, so it escaped every guard while reading as theming surface. It is + * not one — it is a VALUE CHANNEL (the census class of the same name), and a + * theme that pinned it would break the slide. + * + * The recipe POSITIONS + SHAPES from those vars (`translate: + * var(--_tabs-indicator-x) var(--_tabs-indicator-y)`, and `width`/`height` or a + * per-variant override) — the layer never inline-styles a base position, so + * there is no double-offset footgun. A SHARED recipe (`lib/sliding-indicator.css`) + * cannot spell the host's name, so it reads a local alias each consumer declares + * (`--_indicator-x: var(--_radio-group-indicator-x)`) — the palette bridge's + * wiring, contract in the artifact and greppable. * * Layout reads run through `dom.measure` (the coalesced post-layout read queue), * so a measure never forces a synchronous reflow mid-turn. This is the shared @@ -62,6 +82,9 @@ export interface MeasuredIndicatorOpts { * a menu is OPEN — opening IS the measure trigger, so it cannot go stale. */ export class MeasuredIndicator { + /** The host component's kebab — the namespace the channel is written into. */ + private readonly component: string; + /** The active item's offset rect, or `null` before the first measure. */ rect = $state(null); @@ -69,23 +92,25 @@ export class MeasuredIndicator { readonly ready = $derived(this.rect !== null); /** - * The `--indicator-*` custom-property map to spread onto the indicator - * element's style. `undefined` before the first measure (the recipe keeps - * the element at `opacity: 0`). + * The `--_{component}-indicator-*` custom-property map to spread onto the + * indicator element's style. `undefined` before the first measure (the recipe + * keeps the element at `opacity: 0`). */ readonly vars = $derived.by | undefined>(() => { const r = this.rect; if (!r) return undefined; + const c = `--_${this.component}-indicator-`; return { - '--indicator-x': `${r.x}px`, - '--indicator-y': `${r.y}px`, - '--indicator-w': `${r.w}px`, - '--indicator-h': `${r.h}px` + [`${c}x`]: `${r.x}px`, + [`${c}y`]: `${r.y}px`, + [`${c}w`]: `${r.w}px`, + [`${c}h`]: `${r.h}px` }; }); constructor(opts: MeasuredIndicatorOpts) { - const { dom, root, active, dir } = opts; + const { dom, component, root, active, dir } = opts; + this.component = component; $effect(() => { const rootEl = root();