diff --git a/docs/audit/theming/avatar.md b/docs/audit/theming/avatar.md index 2950d1cf1..194b65806 100644 --- a/docs/audit/theming/avatar.md +++ b/docs/audit/theming/avatar.md @@ -5,9 +5,9 @@ > Vista de conjunto: [README](./README.md) · método y protocolo: > [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7. -- **Medido**: 2026-08-23 · **Alcance**: **75%** — 27 de 36 knobs por token público -- **Knobs de apariencia**: 36 — público 27 · privado 3 · global 0 · literal 6 · sistema 0 · excepción 0 _(los dos últimos, fuera del ratio)_ -- **Contrato hoy** (`lib/recipes/base.ts`): **sin entrada en `base.ts`** +- **Medido**: 2026-08-23 · **Alcance**: **90%** — 27 de 30 knobs por token público +- **Knobs de apariencia**: 30 — público 27 · privado 3 · global 0 · literal 0 · sistema 0 · excepción 6 _(los dos últimos, fuera del ratio)_ +- **Contrato hoy** (`lib/recipes/base.ts`): 88 pública(s) — `size-xs`, `size-sm`, `size-md`, `size-lg`, `size-xl`, `size-xxl`, `font-family`, `font-size-xs`, `font-size-sm`, `font-size-md`, `font-size-lg`, `font-size-xl`, `font-size-xxl`, `font-weight`, `radius-full`, `radius-md`, `radius-sm`, `radius-none`, `border-width`, `primary-solid-bg`, `primary-solid-fg`, `primary-soft-bg`, `primary-soft-fg`, `primary-outline-border`, `primary-outline-fg`, `secondary-solid-bg`, `secondary-solid-fg`, `secondary-soft-bg`, `secondary-soft-fg`, `secondary-outline-border`, `secondary-outline-fg`, `neutral-solid-bg`, `neutral-solid-fg`, `neutral-soft-bg`, `neutral-soft-fg`, `neutral-outline-border`, `neutral-outline-fg`, `affirm-solid-bg`, `affirm-solid-fg`, `affirm-soft-bg`, `affirm-soft-fg`, `affirm-outline-border`, `affirm-outline-fg`, `fulfill-solid-bg`, `fulfill-solid-fg`, `fulfill-soft-bg`, `fulfill-soft-fg`, `fulfill-outline-border`, `fulfill-outline-fg`, `risk-solid-bg`, `risk-solid-fg`, `risk-soft-bg`, `risk-soft-fg`, `risk-outline-border`, `risk-outline-fg`, `threat-solid-bg`, `threat-solid-fg`, `threat-soft-bg`, `threat-soft-fg`, `threat-outline-border`, `threat-outline-fg`, `loss-solid-bg`, `loss-solid-fg`, `loss-soft-bg`, `loss-soft-fg`, `loss-outline-border`, `loss-outline-fg`, `ring-width-sm`, `ring-width-md`, `ring-width-lg`, `ring-color-custom`, `badge-border-width`, `badge-color-custom`, `badge-color-custom-contrast`, `group-gap-xs`, `group-gap-sm`, `group-gap-md`, `group-gap-lg`, `group-overlap-xs`, `group-overlap-sm`, `group-overlap-md`, `group-overlap-lg`, `group-overlap-xl`, `group-overlap-xxl`, `group-carve-width`, `group-carve-color`, `group-reverse-z-max`, `overflow-font-weight` · 11 privada(s) forward — `_palette-solid`, `_palette-surface`, `_palette-contrast`, `_palette-text`, `_palette-border`, `_bg`, `_fg`, `_border`, `_badge-bg`, `_badge-fg`, `_badge-border` - **Eje `size`**: sí · **ficheros**: `avatar.css` ## 1. Knobs fuera de alcance @@ -24,7 +24,16 @@ _Ninguno._ | 2 | `avatar.css:39` | `[data-avatar]` | `color` | `var(--_avatar-fg)` | | 3 | `avatar.css:224` | `[data-avatar-badge]` | `background` | `var(--_avatar-badge-bg)` | -### 1.3 Literales (6) +### 1.3 Literales (0) + +_Ninguno._ + +### 1.4 Excepciones firmadas (6) — fuera del ratio + +Literales que llevan su anotación `/* literal: */` en la propia +declaración: la válvula de recipe-contract §3, la misma que honra +`component-audit`. **Una desviación firmada no es deuda** — se listan para que la +razón se lea, no para acuñarlas. | # | fichero:línea | selector | propiedad | valor | | ---: | --- | --- | --- | --- | @@ -34,15 +43,6 @@ _Ninguno._ | 4 | `avatar.css:118` | `[data-avatar-fallback]` | `block-size` | `100%` | | 5 | `avatar.css:123` | `[data-avatar-fallback]` | `line-height` | `1` | | 6 | `avatar.css:231` | `[data-avatar-badge]` | `line-height` | `1` | - -### 1.4 Excepciones firmadas (0) — fuera del ratio - -Literales que llevan su anotación `/* literal: */` en la propia -declaración: la válvula de recipe-contract §3, la misma que honra -`component-audit`. **Una desviación firmada no es deuda** — se listan para que la -razón se lea, no para acuñarlas. - -_Ninguno._ ## 2. Sistema transversal (0) — informativo, fuera del ratio Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2). @@ -62,8 +62,9 @@ _Ninguno._ | `--_avatar-badge-dot-size` | 1 | `calc(var(--_avatar-size) * 0.26)` | private | no | | `--_avatar-badge-flip` | 2 | `1`, `-1` | literal | no | | `--_avatar-badge-bg` | 3 | `var(--avatar-badge-color-custom)`, `color-mix( in srgb, var(--avatar-badge-color-custom) 18%, transparent )`, `var(--color-surface-default)` | global, public | no | -| `--_avatar-badge-fg` | 3 | `var(--avatar-badge-color-custom-contrast, white)`, `var(--avatar-badge-color-custom)` | public | **sí** | +| `--_avatar-badge-fg` | 3 | `var(--avatar-badge-color-custom-contrast)`, `var(--avatar-badge-color-custom)` | public | **sí** | | `--_avatar-badge-border` | 1 | `var(--avatar-badge-color-custom)` | public | **sí** | +| `--_avatar-group-overlap` | 7 | `var(--avatar-group-overlap-md)`, `var(--avatar-group-overlap-xs)`, `var(--avatar-group-overlap-sm)`, `var(--avatar-group-overlap-lg)`, `var(--avatar-group-overlap-xl)`, `var(--avatar-group-overlap-xxl)` | public | **sí** | Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inline del wrapper): `--_avatar-bg`, `--_avatar-border`, `--_avatar-fg`. @@ -105,6 +106,75 @@ no distingue lo que debería — se marca `⚠`. -_(pendiente — lo escribe el autor; se conserva al regenerar)_ +**Ejecutado 2026-08-23 — 75 % → 90 %, contrato 88 claves, centinela 83/88 (5 +adjudicadas).** Diff de computed **0** en tres bases (demo de `avatar` con +insignia + anillo: 576 valores · la misma en modo `fallback`: 384 · demo de +`AvatarGroup`: 3.072) y las dos capturas 2× **idénticas al byte**. + +**Lo primero, porque contaminaba todo lo demás: su contrato existía y ningún +instrumento lo veía.** La entrada `avatar` de `base.ts` es la ÚNICA construida +por una IIFE (un helper local genera sus 24 ámbitos compuestos), así que su mapa +vive un tabulador más adentro, en el `return {`. El censo la leía como «sin +entrada en `base.ts`» —84 claves invisibles— y el centinela **moría** con `no +recipe block for avatar`: el componente no se podía medir. Los dos lectores +leen ya la IIFE (dedentan el `return`). Sin eso, el gate de este componente no +significaba nada. + +**Lo que se acuñó (6) y por qué:** + +- **`group-overlap-{xs..xxl}`** — la escala de solape de `AvatarGroup`. Había + UNA clave, `--avatar-group-overlap`, y la receta la **re-declaraba** en seis + bloques `[data-size]`: sentada en el elemento, ganaba siempre al `:root` donde + escribe un tema. **Medido**: desde el asiento del tema, `37px` no movía el + margen ni un píxel; escrita sobre el nodo, sí. Es un **falso positivo del + centinela** (escribe el token también sobre cada nodo del componente, y para + una propiedad personalizada que la receta re-declara EN EL ELEMENTO ese + inline sí gana), y queda anotado como límite del instrumento. Ahora el paso + viaja por `--_avatar-group-overlap` y los seis alcanzan desde `:root` (−8,4 · + −11,2 · −14 · −16,8 · −22,4 · −33,6 px → 37 px, uno a uno). + +**Lo que se retiró (2 declaraciones muertas, diff 0 las dos):** + +- **`group-max`** — el envoltorio escribía `--avatar-group-max` INLINE y la + receta declaraba su default `99`; **no lo leía nadie**. El tope se aplica con + `data-has-max` + `:nth-child(n + M)` porque una variable no entra en + `:nth-child()` — lo dice el propio comentario del CSS. Retirada de los dos + sitios: el `+3` del grupo sigue exactamente donde estaba. +- **el respaldo `, white`** de `--_avatar-badge-fg`: el contrato ya declara + `--avatar-badge-color-custom-contrast: white`, así que el respaldo era + inalcanzable y sólo podía envejecer contra su token (la clase del `, 1.4` + contra `--font-line-height-sm`). Comprobado en la rama custom: la tinta sigue + computando `rgb(255, 255, 255)`. + +**Seis literales firmados** (salen del ratio, clase `exception`): los cuatro +`100 %` de `Image` y `Fallback` son IDENTIDAD —la parte ES la superficie del +avatar, no una talla propia— y los dos `line-height: 1` mantienen el glifo +centrado por la caja flex; con interlineado se descentra. + +**Lo que se queda fuera, y por qué (los 3 privados, el techo real es 90 %):** +`--_avatar-bg`, `--_avatar-fg` y `--_avatar-badge-bg` son un **CONMUTADOR** — +cambian de fuente con la variante (`solid` · `soft` · `outline`) y su valor sale +del forward de paleta THM-2 que la capa de color alimenta por instancia desde +`[data-color]`. Un público encima dejaría que un tema los fijara y matara el +`color=` de cada avatar (la razón de §3.pre del handoff). Aplanarlos obligaría +además a duplicar cada regla por tono: 24 combinaciones. + +**Las cinco adjudicaciones del centinela** (todas medidas a mano sobre el nodo +REAL, con transiciones congeladas): `size-xxl` y `font-size-xxl` viven en el +paso `xxl`, que **el barrido de tallas del guard no alcanza** (para en `xl` — el +mismo límite que `metrics` ya registró); `radius-none` y `ring-width-sm` son +«sólo el paso en vigor pinta»; `group-overlap-xxl` junta las dos cosas. + +**Defectos reales que NO se arreglan aquí:** + +1. **R-5.3, cuatro claves fuera de la gramática, PREEXISTENTES**: + `ring-color-custom`, `badge-color-custom`, `badge-color-custom-contrast` (y + la cuarta que el audit trunca). La tinta es `fg`, no `color`. Son los + canales de valor que el envoltorio escribe INLINE, así que renombrarlos + cambia el contrato público de tres escotillas — decisión, no ejecución: + queda listado, el audit sigue en NEEDS-WORK por esto y sólo por esto. +2. El barrido de tallas del centinela **no llega a `xxl`**; añadirlo dejaría + STALE las seis excepciones que `metrics` escribió por lo mismo, así que se + respeta el precedente y se adjudica. diff --git a/docs/next-features.md b/docs/next-features.md index 81ac37f9e..1f49e37e7 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -518,6 +518,22 @@ existiera. Se anotan porque el eje entero se apoya en esas mediciones. `command.input-placeholder-fg` una entrada de ledger «verificada a mano» que ha salido STALE y se ha borrado. El pseudo entra ahora en el snapshot del guard, junto a `::before`/`::after`. +- **El centinela da FALSOS POSITIVOS cuando la receta re-declara el público en + el ELEMENTO** (medido 2026-08-23 en `avatar`). El guard escribe el token en + `:root` **y sobre cada nodo del componente** —para alcanzar un panel portalado + que no ve la raíz— y el comentario que justifica eso dice que escribir en + todas partes «no puede fingir una victoria a nivel de PROPIEDAD». Cierto para + una propiedad; **falso para una propiedad personalizada** que la receta vuelve + a declarar en un bloque `[data-size]`: ahí el inline del guard gana justo lo + que un tema pierde. `--avatar-group-overlap` leía VIVO y desde `:root` no + movía un píxel. No hay arreglo obvio (quitar la escritura sobre el nodo + resucita el punto ciego del portal); mientras tanto, **un público que la + receta re-declare sobre el elemento se mide a mano desde `:root`**. +- **El barrido de tallas del centinela para en `xl`.** Dos componentes tienen + paso `xxl` (`metrics`, `avatar`) y sus claves leen muertas: ocho excepciones + de ledger escritas por ese motivo. Añadir el paso es una línea, pero deja + STALE las seis de `metrics`, así que se hace con la re-verificación del + ledger entero, no en el commit de un componente. - **El centinela no ve lo que pinta un componente COMPUESTO.** Los tokens que media-player reenvía al Slider (`--media-player-track` → `--slider-track-bg`) pintan en nodos `[data-slider-*]`, fuera del espacio de diff --git a/docs/process/PLAN-theming.md b/docs/process/PLAN-theming.md index bc8638cf6..09bd5fc37 100644 --- a/docs/process/PLAN-theming.md +++ b/docs/process/PLAN-theming.md @@ -486,6 +486,28 @@ commits de corrección. ## 8. Registro +- 2026-08-23 — **`avatar` 75 % → 90 %** (COMMIT), contrato 84 → 88 claves, + centinela **83/88** con 5 adjudicadas. **Su contrato existía y NINGÚN + instrumento lo veía**: es la única entrada de `base.ts` construida por una + IIFE (un helper local genera sus 24 ámbitos compuestos), así que su mapa vive + en el `return {`, un tabulador más adentro — el censo la daba «sin entrada» y + **el centinela moría** (`no recipe block for avatar`). Los dos lectores leen + ya la IIFE. Lo demás: **`group-overlap-{xs..xxl}`**, porque la clave única + `--avatar-group-overlap` la RE-DECLARABA la receta en seis bloques + `[data-size]` —sentada en el elemento, ganaba al `:root` de un tema: medido, + no movía nada desde el asiento del tema, y **el centinela lo daba por vivo + porque escribe el token también sobre el nodo** (falso positivo nuevo, §13)—; + **`group-max` RETIRADO** de la receta y del envoltorio (el tope lo aplica + `data-has-max` + `:nth-child(n + M)`, y la variable inline no la leía nadie); + el respaldo `, white` de `--_avatar-badge-fg`, muerto contra su propio token; + y **seis literales de identidad firmados**. Los 3 privados que quedan son el + CONMUTADOR de variante sobre el forward de paleta THM-2: 90 % es su techo. + Artefactos: diff de computed 0 en tres bases (576 + 384 + 3.072 valores), + capturas 2× idénticas al byte, `rtl:check` 0, `docs:check` 0, `check` sin un + solo error en `avatar`. **Queda R-5.3 preexistente**: cuatro claves + `*-color-custom*` donde la gramática exige `fg` — renombrarlas cambia tres + escotillas públicas, así que se lista. + - 2026-08-23 — **REVISIÓN DE LOS 26 COMMITS SIN REVISAR (§7.7 incluida)**: `REVIEW-theming-2026-08-23.md`. 33 componentes, los de los bloques del 22 y el 23. **5 errores reales, 5 desviaciones de protocolo, y la §7.7 con 5 hipótesis diff --git a/scripts/__theming-probe.ts b/scripts/__theming-probe.ts index 403ecaebf..7f84a0833 100644 --- a/scripts/__theming-probe.ts +++ b/scripts/__theming-probe.ts @@ -112,6 +112,14 @@ const OPEN_TRIGGER: Record = { }; const DEMO_VARIANTS: Record = { + // Its badge is OPT-IN and boots in DOT mode (no text frame), and its ring is + // off: the probe saw 2 nodes — the portrait and its image — and neither the + // chip nor the halo the recipe paints. + avatar: [ + '[data-uix-control]:has([data-uix-control-label]:text-is("show badge")) input[type=checkbox]', + '[data-uix-control]:has([data-uix-control-label]:text-is("dot")) input[type=checkbox]', + '[data-uix-control]:has([data-uix-control-label]:text-is("ring")) [data-uix-chip]:text-is("solid")' + ], // It renders only in the media-player demo, after the `media: audio` chip. 'audio-player': [ '[data-uix-control]:has([data-uix-control-label]:text-is("media")) [data-uix-chip]:text-is("audio")' diff --git a/scripts/theming-census.ts b/scripts/theming-census.ts index 4d087abaf..fcd0a9c7d 100644 --- a/scripts/theming-census.ts +++ b/scripts/theming-census.ts @@ -218,6 +218,16 @@ function classify( } function recipeBlock(c: string): string | null { + // `avatar` is the ONLY entry whose value is an IIFE (a local matrix helper + // generates its 24 composite scopes), so its map lives in the `return {` one + // tab deeper and this reader saw NO contract for it — 84 keys reported as + // «sin entrada en base.ts». Read the returned map and dedent it once; the + // key regexes below work unchanged. + const iife = CONTRACT.indexOf('\n\t' + c + ': ((): RecipeTokenMap => {'); + if (iife >= 0) { + const from = CONTRACT.indexOf('\n\t\treturn {', iife); + return CONTRACT.slice(from, CONTRACT.indexOf('\n\t\t};', from)).replace(/^\t/gm, ''); + } const a = CONTRACT.indexOf("\n\t'" + c + "': {"); const b = CONTRACT.indexOf('\n\t' + c + ': {'); const start = a >= 0 ? a : b; diff --git a/scripts/theming-sentinel-exceptions.ts b/scripts/theming-sentinel-exceptions.ts index 901ea9825..2be245f21 100644 --- a/scripts/theming-sentinel-exceptions.ts +++ b/scripts/theming-sentinel-exceptions.ts @@ -21,6 +21,21 @@ const PLANE_SUPERSEDED = 'SUPERSEDED by the depth plane: the part carries data-depth=overlay, and [data-depth=overlay] declares this property at the same specificity (0,1,0) and LATER in the cascade, so the recipe key is bypassed (see next-features 12.9, measured on tooltip, popover and float-panel)'; export const SENTINEL_EXCEPTIONS: Record> = { + avatar: { + // 2026-08-23. Five steps of scales the measured stage does not wear, all + // forced on the REAL nodes with transitions frozen. Two of them sit on the + // `xxl` step, which the size sweep of the guard does not reach (the same + // limit `metrics` recorded below). + 'size-xxl': + 'the size sweep of the guard stops at xl; forced data-size=xxl -> reaches (block-size 96px -> 1234px)', + 'font-size-xxl': + 'same xxl step, read on the fallback initials (the image carries no font); forced -> reaches (28px -> 37px)', + 'radius-none': 'only the step in force paints; forced data-radius=none -> reaches (0px -> 37px)', + 'ring-width-sm': + 'only the step in force paints (the demo boots ringWidth=md); forced data-ring-width=sm -> reaches (halo 1.5px -> 17px)', + 'group-overlap-xxl': + 'same xxl step, on AvatarGroup; forced data-size=xxl on the real group -> reaches (margin-inline-start -33.6px -> 37px)' + }, background: { // 2026-08-23. Its parts are independent OPT-IN layers and only ONE pattern // renders at a time, so the default stage moved 3 of 37 tokens. The guard diff --git a/scripts/theming-sentinel.ts b/scripts/theming-sentinel.ts index 434485e61..8ffa31b63 100644 --- a/scripts/theming-sentinel.ts +++ b/scripts/theming-sentinel.ts @@ -171,6 +171,22 @@ const COMPONENT_OVERRIDES: Record< sweepAttr?: { attr: string; values: string[] }; } > = { + // Two thirds of its surface is OPT-IN or lives on another route: the badge is + // off by default (and boots in DOT mode, where the chip has no text frame), + // the ring is off, and AvatarGroup — same recipe, same `data-avatar-group` + // prefix — is a component with its own demo. Without this the guard saw the + // bare portrait: no badge, no halo, no stack. + avatar: { + prepareWith: [ + '[data-uix-control]:has([data-uix-control-label]:text-is("show badge")) input[type=checkbox]', + '[data-uix-control]:has([data-uix-control-label]:text-is("dot")) input[type=checkbox]', + '[data-uix-control]:has([data-uix-control-label]:text-is("ring")) [data-uix-chip]:text-is("solid")' + ], + // The 16 `*-outline-*` keys only paint under the outline variant, on the + // badge (the root reads its border through the shared palette forward). + sweepAttr: { attr: 'data-variant', values: ['solid', 'soft', 'outline'] }, + urls: ['/uix/components/avatar', '/uix/components/avatar-group'] + }, 'picker-shell': { attrPrefix: 'data-picker', openWith: ['[data-uix-stage-area] [data-popover-trigger]'], @@ -438,13 +454,25 @@ async function main() { /\r\n/g, '\n' ); - const start = Math.max( - contract.indexOf(`\n\t'${component}': {`), - contract.indexOf(`\n\t${component}: {`) - ); + // `avatar` is the ONLY entry whose value is an IIFE (a local matrix helper + // generates its 24 composite scopes), so its map lives in the `return {` one + // tab deeper: this probe found no block at all and the guard threw «no recipe + // block for avatar» — the component could not be measured. Read the returned + // map and dedent it once; everything downstream works unchanged. + const iife = contract.indexOf(`\n\t${component}: ((): RecipeTokenMap => {`); + const start = + iife >= 0 + ? contract.indexOf('\n\t\treturn {', iife) + : Math.max( + contract.indexOf(`\n\t'${component}': {`), + contract.indexOf(`\n\t${component}: {`) + ); if (start < 0) throw new Error(`no recipe block for ${component}`); - const end = contract.indexOf('\n\t},', start); - const block = contract.slice(start, end < 0 ? undefined : end); + const end = contract.indexOf(iife >= 0 ? '\n\t\t};' : '\n\t},', start); + const block = + iife >= 0 + ? contract.slice(start, end).replace(/^\t/gm, '') + : contract.slice(start, end < 0 ? undefined : end); const keys = [...block.matchAll(/^\t\t'?([a-z0-9-]+)'?\s*:/gm)] .map((m) => m[1]) .filter((k) => !k.startsWith('_')); diff --git a/src/uix/eidos/components/avatar/README.md b/src/uix/eidos/components/avatar/README.md index 89468dbed..143588d72 100644 --- a/src/uix/eidos/components/avatar/README.md +++ b/src/uix/eidos/components/avatar/README.md @@ -15,6 +15,36 @@ Bits UI, Ark/Reka y shadcn-svelte. Partes publicas: `Image`, `Fallback`. +## Talla y tema + +88 claves públicas. Las escalas van por talla con su nombre resuelto +(`size-{xs..xxl}`, `font-size-{xs..xxl}`, `ring-width-{sm,md,lg}`) y la forma +por `radius-{full,md,sm,none}`; el tono entra por la matriz +`{tono}-{solid,soft,outline}-{bg,fg,border}` que la receta compone en TSC v2.2. + +Lo que entró el **2026-08-23** (75 % → 90 %): + +- **`group-overlap-{xs..xxl}`**, la escala de solape de `AvatarGroup`. Había una + sola clave, `--avatar-group-overlap`, y la receta la **RE-DECLARABA** en seis + bloques `[data-size]`: escrita en el elemento, ganaba siempre al `:root` donde + un tema escribe. Medido — desde el asiento del tema no movía nada, y sólo + parecía viva porque el centinela escribe también sobre el nodo. Ahora el paso + viaja por `--_avatar-group-overlap` y los seis alcanzan desde `:root` + (−8,4 px … −33,6 px → 37 px, uno a uno). +- **`--avatar-group-max` RETIRADO**, de la receta y del envoltorio. El tope se + aplica con `data-has-max` + `:nth-child(n + M)` —una variable no entra en + `:nth-child()`— así que la propiedad que el wrapper escribía inline no la leía + nadie, y su default `99` tampoco. Diff de computed 0 al quitarla. +- **Seis literales firmados**: los `100 %` de `Image` y `Fallback` son identidad + (la parte ES la superficie del avatar, no una talla propia) y los dos + `line-height: 1` mantienen el glifo centrado por la caja flex. + +**Lo que queda privado, y por qué**: `--_avatar-bg` / `--_avatar-fg` / +`--_avatar-badge-bg` son un CONMUTADOR — cambian de fuente con la variante +(`solid` · `soft` · `outline`) y su valor sale del forward de paleta THM-2 que +la capa de color alimenta por instancia desde `[data-color]`. Un público encima +dejaría que un tema los fijara y matara el `color=` de cada avatar. + ## Comparativa | Capacidad | Eidos | Radix | Bits UI | Ark/Reka | shadcn-svelte | diff --git a/src/uix/eidos/components/avatar/avatar-group.svelte b/src/uix/eidos/components/avatar/avatar-group.svelte index b24657d24..903c3b715 100644 --- a/src/uix/eidos/components/avatar/avatar-group.svelte +++ b/src/uix/eidos/components/avatar/avatar-group.svelte @@ -58,14 +58,10 @@ max !== undefined && total !== undefined && total > max ? total - max : 0 ); - // Surface `--avatar-group-max` so the recipe can hide children past - // the cap. Counted via :nth-child(n+M). - const cssMax = $derived(max ?? ''); - const inlineStyle = $derived( - [cssMax !== '' && `--avatar-group-max: ${cssMax};`, styleProp] - .filter(Boolean) - .join(' ') || undefined - ); + // No `--avatar-group-max` here: the cap is applied by `data-has-max` + + // `:nth-child(n + M)` (a custom property cannot enter `:nth-child()`), so the + // variable this file used to write was read by nobody — and its recipe + // default with it. Both retired 2026-08-23, computed diff 0. {@render children?.()} {#if overflowCount > 0} diff --git a/src/uix/eidos/components/avatar/avatar.css b/src/uix/eidos/components/avatar/avatar.css index 255757f8f..cc9e96c24 100644 --- a/src/uix/eidos/components/avatar/avatar.css +++ b/src/uix/eidos/components/avatar/avatar.css @@ -95,8 +95,8 @@ inset: 0; z-index: 1; display: block; - inline-size: 100%; - block-size: 100%; + inline-size: 100%; /* literal: identity — the image IS the avatar's surface, not a size of its own */ + block-size: 100%; /* literal: identity — the image IS the avatar's surface, not a size of its own */ object-fit: cover; border-radius: inherit; } @@ -114,13 +114,13 @@ display: inline-flex; align-items: center; justify-content: center; - inline-size: 100%; - block-size: 100%; + inline-size: 100%; /* literal: identity — the fallback IS the avatar's surface, not a size of its own */ + block-size: 100%; /* literal: identity — the fallback IS the avatar's surface, not a size of its own */ border-radius: inherit; font-family: var(--avatar-font-family); font-size: var(--_avatar-font-size); font-weight: var(--avatar-font-weight); - line-height: 1; + line-height: 1; /* literal: the initials are centred by the flex box; leading would decentre them */ } /* Custom fill colour: the wrapper stamps `data-color-custom` + the shared @@ -228,7 +228,7 @@ font-family: var(--avatar-font-family); font-size: calc(var(--_avatar-size) * 0.22); /* literal: badge glyph is physically proportional to the avatar diameter */ font-weight: var(--avatar-font-weight); - line-height: 1; + line-height: 1; /* literal: the chip glyph is centred by the flex box; leading would decentre it */ white-space: nowrap; pointer-events: none; } @@ -283,7 +283,10 @@ /* Custom badge color — value comes from inline style. */ [data-avatar-badge][data-color-custom] { --_avatar-badge-bg: var(--avatar-badge-color-custom); - --_avatar-badge-fg: var(--avatar-badge-color-custom-contrast, white); + /* No hand-written fallback: the contract already declares + * `--avatar-badge-color-custom-contrast: white`, so `, white` here was dead + * code that could only age against it. */ + --_avatar-badge-fg: var(--avatar-badge-color-custom-contrast); } [data-avatar-badge][data-variant='soft'][data-color-custom] { --_avatar-badge-bg: color-mix( @@ -307,24 +310,33 @@ } /* Overlap distance scales with the propagated size so a group of `xs` - * avatars overlaps less in absolute pixels than a group of `xl`. */ + * avatars overlaps less in absolute pixels than a group of `xl`. + * + * The step travels through a PRIVATE. Declaring the public + * `--avatar-group-overlap` here — which is what this cascade used to do — + * seated the token on the element, where it beat the `:root` a theme writes: + * measured 2026-08-23, the public moved nothing from the theme's seat and only + * looked alive to the guard because the guard writes on the node too. */ +[data-avatar-group] { + --_avatar-group-overlap: var(--avatar-group-overlap-md); +} [data-avatar-group][data-size='xs'] { - --avatar-group-overlap: calc(var(--avatar-size-xs) * -0.35); + --_avatar-group-overlap: var(--avatar-group-overlap-xs); } [data-avatar-group][data-size='sm'] { - --avatar-group-overlap: calc(var(--avatar-size-sm) * -0.35); + --_avatar-group-overlap: var(--avatar-group-overlap-sm); } [data-avatar-group][data-size='md'] { - --avatar-group-overlap: calc(var(--avatar-size-md) * -0.35); + --_avatar-group-overlap: var(--avatar-group-overlap-md); } [data-avatar-group][data-size='lg'] { - --avatar-group-overlap: calc(var(--avatar-size-lg) * -0.35); + --_avatar-group-overlap: var(--avatar-group-overlap-lg); } [data-avatar-group][data-size='xl'] { - --avatar-group-overlap: calc(var(--avatar-size-xl) * -0.35); + --_avatar-group-overlap: var(--avatar-group-overlap-xl); } [data-avatar-group][data-size='xxl'] { - --avatar-group-overlap: calc(var(--avatar-size-xxl) * -0.35); + --_avatar-group-overlap: var(--avatar-group-overlap-xxl); } /* Side-by-side: simple flat row with gap. */ @@ -346,7 +358,7 @@ * matching the parent surface) keeps each silhouette legible. */ [data-avatar-group][data-stacking='overlap'] > [data-avatar], [data-avatar-group][data-stacking='overlap-reverse'] > [data-avatar] { - margin-inline-start: var(--avatar-group-overlap); + margin-inline-start: var(--_avatar-group-overlap); } [data-avatar-group][data-stacking='overlap'] diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 94ec92acc..ed5476b94 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -646,11 +646,15 @@ --avatar-group-gap-sm: var(--space-2); --avatar-group-gap-md: var(--space-3); --avatar-group-gap-lg: var(--space-4); - --avatar-group-overlap: -0.6em; + --avatar-group-overlap-xs: calc(var(--avatar-size-xs) * -0.35); + --avatar-group-overlap-sm: calc(var(--avatar-size-sm) * -0.35); + --avatar-group-overlap-md: calc(var(--avatar-size-md) * -0.35); + --avatar-group-overlap-lg: calc(var(--avatar-size-lg) * -0.35); + --avatar-group-overlap-xl: calc(var(--avatar-size-xl) * -0.35); + --avatar-group-overlap-xxl: calc(var(--avatar-size-xxl) * -0.35); --avatar-group-carve-width: var(--border-width-medium); --avatar-group-carve-color: var(--color-surface-default); --avatar-group-reverse-z-max: 20; - --avatar-group-max: 99; --avatar-overflow-font-weight: var(--font-weight-medium); --aura-status-font-size-md: var(--size-sm-font-size); --aura-status-line-height-md: var(--size-sm-font-line-height); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 011274fe8..d87c4c9aa 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -232,11 +232,19 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({ 'group-gap-sm': 'var(--space-2)', 'group-gap-md': 'var(--space-3)', 'group-gap-lg': 'var(--space-4)', - 'group-overlap': '-0.6em', + // One step per size, because the pull-back is proportional to the + // diameter. It used to be a single key that the recipe RE-DECLARED in six + // `[data-size]` blocks, i.e. on the element — where it beat the `:root` a + // theme writes and no theme could reach it (measured 2026-08-23). + 'group-overlap-xs': 'calc(var(--avatar-size-xs) * -0.35)', + 'group-overlap-sm': 'calc(var(--avatar-size-sm) * -0.35)', + 'group-overlap-md': 'calc(var(--avatar-size-md) * -0.35)', + 'group-overlap-lg': 'calc(var(--avatar-size-lg) * -0.35)', + 'group-overlap-xl': 'calc(var(--avatar-size-xl) * -0.35)', + 'group-overlap-xxl': 'calc(var(--avatar-size-xxl) * -0.35)', 'group-carve-width': 'var(--border-width-medium)', 'group-carve-color': 'var(--color-surface-default)', 'group-reverse-z-max': '20', - 'group-max': '99', 'overflow-font-weight': 'var(--font-weight-medium)', // ── TSC v2.2 — composite (variant × color) cascade ──────────────── // 8 colors × 3 variants = 24 composite scopes per slot, applied diff --git a/web/routes/uix/components/avatar/+page.svelte b/web/routes/uix/components/avatar/+page.svelte index 4f5d835ce..4f244f188 100644 --- a/web/routes/uix/components/avatar/+page.svelte +++ b/web/routes/uix/components/avatar/+page.svelte @@ -13,6 +13,7 @@ import { compileMorfo } from '$uix/morfo'; import { avatarMorfo } from '@/uix/morfo/components/avatar'; import { PALETTE_SCALES } from '../../lib/harness.svelte'; + import TokensPanel from '../../lib/TokensPanel.svelte'; // Roles + the 33 palette scales — the avatar's `color` accepts any (a scale // routes through the shared per-instance layer; THM-2 §25.5). @@ -28,7 +29,7 @@ ...PALETTE_SCALES ] as const; - type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; + type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y'; type TraceEntry = { event: string; family: string; intent?: string; at: number }; let tab = $state('live'); let trace = $state([]); @@ -212,6 +213,9 @@ + @@ -850,6 +854,10 @@ {/if} + {#if tab === 'tokens'} + + {/if} + {#if tab === 'recipe'}

Eidos recipe