uix(avatar): temable — 75 % → 90 %, y su contrato era INVISIBLE para los dos instrumentos

Protocolo PLAN-theming §7 sobre `avatar` (incluye `AvatarGroup`, que comparte
receta y fichero). Censo 75 % → **90 %**, contrato 84 → 88 claves, centinela
**83/88** con 5 adjudicadas. El default NO se mueve.

## Lo primero: el instrumento no veía el contrato

La entrada `avatar` de `recipes/base.ts` es la ÚNICA 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. Consecuencia medida:

- el censo la leía como «sin entrada en base.ts» — 84 claves invisibles;
- el centinela **moría** con `no recipe block for avatar`: el componente no se
  podía medir, y su gate no significaba nada.

Los dos lectores leen ya la IIFE (dedentan el `return`). Sin este arreglo, todo
lo demás de este commit habría sido humo.

## Lo que entra al contrato (6 claves)

`group-overlap-{xs,sm,md,lg,xl,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: `37px` desde el asiento del tema no movía el margen; el
mismo valor escrito sobre el nodo, sí. 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 retira (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()`, y lo dice el propio comentario del CSS. Retirado 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 era inalcanzable y sólo
  podía envejecer contra su token. Comprobado en la rama custom: la tinta sigue
  computando `rgb(255, 255, 255)`.

## Seis literales firmados (salen del ratio)

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.

## Lo que se queda privado (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.

## Artefactos

- Sonda: **0 diffs** en TRES bases — demo de avatar con insignia y anillo (576
  valores), la misma en modo fallback (384) y demo de AvatarGroup (3.072).
- Capturas 2× antes/después de las dos demos: **idénticas al byte**.
- Centinela R-5.4: 83/88, 5 adjudicadas con su medida en el ledger (`size-xxl` y
  `font-size-xxl` viven en el paso `xxl`, que el barrido del guard no alcanza —
  el 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).
- El guard aprende `avatar`: la insignia y el anillo son OPT-IN, el barrido de
  `data-variant` destapa las 16 claves `*-outline-*`, y `AvatarGroup` se mide en
  su propia ruta (21 muertos → 5).
- `eidos-lint` 0 invalid · `vitest src/uix/eidos` sin rojos nuevos (el conocido
  `skin-media-player`) · `rtl:check` 0 · `docs:check` 0 · `npm run check`
  COMPLETED sin un solo error en `avatar`.
- README «Talla y tema» + pestaña `Tokens` (88 filas, verificada en el navegador
  con `networkidle` y clic real).

## Lo que NO se arregla aquí (anotado)

- **R-5.3 preexistente**: cuatro claves `*-color-custom*` donde la gramática
  exige `fg`. Son escotillas que el envoltorio escribe INLINE, así que
  renombrarlas cambia el contrato público de tres canales: se lista, el audit
  sigue en NEEDS-WORK por esto y sólo por esto.
- **Falso POSITIVO nuevo del centinela** (next-features §13): escribe el token
  en `:root` y también sobre cada nodo, y para una propiedad personalizada que
  la receta re-declara en el ELEMENTO ese inline gana justo lo que un tema
  pierde. Es lo que hacía que `--avatar-group-overlap` leyera vivo.
- **El barrido de tallas del guard para en `xl`**: añadir `xxl` dejaría STALE
  las seis excepciones de `metrics`, así que se hace con la re-verificación del
  ledger entero.

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

@ -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: <razón> */` 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: <razón> */` 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 `⚠`.
<!-- veredicto:start -->
_(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.
<!-- veredicto:end -->

@ -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

@ -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

@ -112,6 +112,14 @@ const OPEN_TRIGGER: Record<string, string> = {
};
const DEMO_VARIANTS: Record<string, string[]> = {
// 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")'

@ -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;

@ -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<string, Record<string, string>> = {
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

@ -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('_'));

@ -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 |

@ -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.
</script>
<span
@ -76,7 +72,7 @@
data-gap={stacking === 'side-by-side' ? gap : undefined}
data-has-max={max !== undefined ? String(max) : undefined}
role="group"
style={inlineStyle}
style={styleProp}
>
{@render children?.()}
{#if overflowCount > 0}

@ -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']

@ -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);

@ -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

@ -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<Tab>('live');
let trace = $state<TraceEntry[]>([]);
@ -212,6 +213,9 @@
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}
>Recipe</button
>
<button data-uix-tab data-active={tab === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <span data-uix-tab-count>88</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -850,6 +854,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="avatar" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.