test(uix): audit Avatar native behavior

astra
dev 1 week ago
parent 013a30e017
commit d903ea8a89

@ -285,19 +285,35 @@ tests, docs 971/arts 25, `check:gate` 89 históricos/cero nuevos
src/scripts, apps/base check/build y smoke foundation 1/1 + boot
27/27.
Después, [Avatar](./audit-uix-2026-09-19/avatar-native.md)
reviewed/censo 158: 19 positivos, dos `AssertionError` Q14, cero
pendientes y huella positiva exacta. Veintiún PNG originales
inspeccionados/archivados/SHA, incluidos los negativos y un estado
RTL. Demora del root, callback del root, imagen load/error, grupos,
Badge y estilo medidos. Q14 confirmó la demora ignorada de Fallback
y el callback no conectado de Image anidada; ambos diferidos. Air
existe en `morfo-runtime`, conciliado en README. Wrapper, tipos,
CSS, receta y API ejecutable intactos; sólo README y comentario
del barrel cambiaron. Guard PASS 1. Primera gate detenida en formato
README; segunda gate exit 0: 597 archivos/7244 tests, docs 972/
arts 25, `check:gate` 89 históricos/cero nuevos src/scripts y
apps/base check/build/smoke foundation 1/1 + boot 27/27.
Esta continuación documenta las fichas de Heading,
Text, Display, Link, Prose, Label, Box, Stack, Group, Container, Section,
AutoGrid, Float, Affix, Sticky, Wrap, Separator, AspectRatio, Flex,
Grid y Badge, plan/registro/handoffs, README de Text,
Display, Prose, Group y Badge, comentarios de tipo Prose y Morfo Badge,
Grid, Badge y Avatar, plan/registro/handoffs, README de Text,
Display, Prose, Group, Badge y Avatar, comentarios de tipo Prose,
Morfo Badge y barrel Avatar,
y tests/helpers nuevos.
`.claude/settings.local.json` y `.vitest-attachments/` son del usuario
y siguen excluidos.
## Continuación segura
TagGroup es el siguiente candidato pendiente; la
[ficha de familia](./audit-uix-2026-09-19/badge-tag-group.md) aporta el contexto previo. Releer antes las cuatro guías
Image es el siguiente candidato pendiente; la
[ficha de familia](./audit-uix-2026-09-19/avatar-image.md) aporta el contexto previo. TagGroup ya tenía [auditoría nativa](./audit-uix-2026-09-19/tag-group.md)
reviewed/censo 104 desde el 28 de septiembre. Releer antes las cuatro guías
obligatorias de `AGENTS.md`. Mantener el alcance neutral autorizado:
baseline nativa, huella exacta, capturas originales inspeccionadas y
registro separado de defectos. No editar manualmente el árbol congelado
@ -329,7 +345,8 @@ La misma cautela aplica a `tmp/sticky-audit-evidence-2026-09-30/`,
`tmp/aspect-ratio-audit-evidence-2026-09-30/` y
`tmp/flex-audit-evidence-2026-09-30/` y
`tmp/grid-audit-evidence-2026-09-30/` y
`tmp/badge-audit-evidence-2026-09-30/`.
`tmp/badge-audit-evidence-2026-09-30/` y
`tmp/avatar-audit-evidence-2026-09-30/`.
Para gate/Vitest, apuntar `TEMP` y `TMP` a `node_modules/.cache`, capturar
`LASTEXITCODE` inmediatamente y no mutar archivos durante la gate.
La primera gate de Code sufrió un timeout Playwright de PickerShell

@ -42,6 +42,7 @@ pendiente.
| [Flex](./audit-uix-2026-09-19/flex.md) | reviewed/155 | 19 positivos, 0 negativos/pendientes | 19 PNG/SHA, 1 a 350px | Q28 heredado; ninguno propio nuevo | 594/7180 |
| [Grid](./audit-uix-2026-09-19/grid.md) | reviewed/156 | 25 positivos, 0 negativos/pendientes | 25 PNG/SHA, 1 a 350px | Q28 heredado; ninguno propio nuevo | 595/7205 |
| [Badge](./audit-uix-2026-09-19/badge-native.md) | reviewed/157 | 20 positivos, 3 `AssertionError`, 0 pendientes | 23 PNG/SHA, 1 a 350px | Q12: `as`, `visibleFrom`, `padding` de Box | 596/7225 |
| [Avatar](./audit-uix-2026-09-19/avatar-native.md) | reviewed/158 | 19 positivos, 2 `AssertionError`, 0 pendientes | 21 PNG/SHA, 1 RTL | Q14: demora Fallback y callback Image | 597/7244 |
Los positivos promovidos conservaron nombres/estados exactos frente a
cada baseline. Los tests y dos helpers por componente viven fuera de
@ -112,11 +113,27 @@ archivos y 7225/7225 pruebas, docs 971/arts 25, `check:gate` 89
históricos/cero nuevos src/scripts y apps/base check/build/smoke
foundation 1/1 + boot 27/27.
Avatar dio 19/19 canarios positivos promovidos con huella exacta y
dos `AssertionError` Q14 separados. Se inspeccionaron/SHA los 21
PNG originales, incluidos los dos negativos y uno RTL. El root
sí demora el fallback y recibe la transición `loaded`; las props
de Fallback e Image anidada no lo hacen. Q14 sigue diferido.
Se midieron escalas, variantes, color, anillo, Badge, grupo y
carga/error de imagen. Air histórico existe en `morfo-runtime`.
Sólo se conciliaron README y comentario de `index.ts`; lógica, CSS
y API ejecutable intactos. Primera gate detenida por formato del
README; segunda gate integral exit 0: 597/597 archivos y 7244/7244
pruebas, docs 972/arts 25, `check:gate` 89 históricos/cero nuevos
src/scripts y apps/base check/build/smoke foundation 1/1 + boot
27/27.
## Continuación segura
[TagGroup](./audit-uix-2026-09-19/badge-tag-group.md) es el siguiente candidato.
[Image](./audit-uix-2026-09-19/avatar-image.md) es el siguiente candidato.
TagGroup ya tenía [auditoría nativa](./audit-uix-2026-09-19/tag-group.md)
reviewed/censo 104 desde el 28 de septiembre.
Grid, Flex, AspectRatio, Separator y Wrap no abrieron un defecto nuevo;
Sticky amplió Q34 y L-152, y Badge confirmó Q12. Antes de
Sticky amplió Q34 y L-152, Badge confirmó Q12 y Avatar confirmó Q14. Antes de
crear o modificar el componente, releer las cuatro guías obligatorias
de `AGENTS.md`. Mantener auditoría neutral: preflight, baseline nativa,
huella exacta, capturas originales inspeccionadas y hallazgos diferidos
@ -127,7 +144,7 @@ el inventario y las decisiones.
No editar manualmente `web/` ni usar reset/stash/checkout. Los cambios
locales `.claude/settings.local.json` y `.vitest-attachments/` son del
usuario y se excluyeron de los commits de este lote. La evidencia
ignorada está en `tmp/{heading,text,display,link,prose,label,box,stack,group,container,section,auto-grid,float,affix,sticky,wrap,separator,aspect-ratio,flex,grid,badge}-audit-evidence-2026-09-30/`;
ignorada está en `tmp/{heading,text,display,link,prose,label,box,stack,group,container,section,auto-grid,float,affix,sticky,wrap,separator,aspect-ratio,flex,grid,badge,avatar}-audit-evidence-2026-09-30/`;
archivo de capturas y promoción ya se ejecutaron, así que no repetir
esas mutaciones. Para gate/Vitest, apuntar `TEMP` y `TMP` a
`node_modules/.cache`, capturar `$LASTEXITCODE` inmediatamente y no

@ -4027,4 +4027,28 @@ comentario de Morfo y el README. Cero extracción de lógica.
integral exit 0: 596 archivos/7225 pruebas, docs 971/arts 25,
`check:gate` 89 históricos/cero nuevos src/scripts, apps/base
check/build y smoke foundation 1/1 + boot 27/27. Fase 6 abierta,
fases 0–5 sin cerrar y fase 7 pendiente. TagGroup continúa la familia.
fases 0–5 sin cerrar y fase 7 pendiente. TagGroup ya tenía auditoría
nativa reviewed/censo 104; Avatar fue el siguiente candidato abierto.
### Avatar — cierre neutral global (2026-09-30)
Avatar se auditó con baseline nativa: 19 canarios positivos, dos
`AssertionError` Q14 y cero pendientes. Los positivos promovidos
conservaron la huella exacta; dos helpers y los tests viven fuera del
wrapper. Se inspeccionaron/archivaron con SHA 21 PNG originales,
incluidas las dos sondas negativas y un estado RTL. Se midieron
variantes, escalas, radio, color, anillos, Badge, carga/error de imagen,
demora del root, grupos y reactividad. Q14 quedó confirmado en sus
dos props; la corrección se difiere. El solape de un grupo `xl` con
un hijo `sm` queda como límite visual de composición por adjudicar.
Air sí existe en la rama histórica `morfo-runtime`; README y comentario
de `index.ts` se conciliaron con la implementación. Wrapper, tipos,
CSS, receta y API ejecutable intactos; cero extracción de lógica.
[Ficha](./audit-uix-2026-09-19/avatar-native.md) reviewed/censo 158.
`component:audit --only avatar` PASS 1/cero errores/avisos. Primera
gate detenida por formato de README; segunda gate integral exit 0:
597 archivos/7244 pruebas, docs 972/arts 25, `check:gate` 89
históricos/cero nuevos src/scripts, apps/base check/build y smoke
foundation 1/1 + boot 27/27. Fase 6 abierta, fases 0–5 sin cerrar
y fase 7 pendiente. Image continúa la familia visual.

@ -55,3 +55,10 @@ de corrección deberá cubrir también `errorFallback`, Error compuesto y
suite cliente específica de estos wrappers ni de `ImageProvider`. Una sonda
temporal de Chromium reprodujo Q14–Q16 (1/1, retirada después). No se hizo
comparación visual manual. Reducción de producción: **0 líneas**.
La [continuación nativa de Avatar](./avatar-native.md) del 2026-09-30
confirmó las dos props de Q14 con 19 positivos y dos fallas separadas,
inspeccionó 21 PNG y dejó sus tests positivos permanentes. También leyó
Air en `morfo-runtime:src/uix/air/components/avatar/`; la ausencia
citada en el README antiguo correspondía a otra rama. Image sigue
pendiente de una baseline nativa individual para Q15–Q16.

@ -0,0 +1,71 @@
---
title: UIX — Avatar, identidad y ciclo de imagen
type: notes
audience: human + agent
authority: process — auditoría de componente, no contrato nuevo
status: reviewed
---
# Avatar — continuación nativa
Fecha: 2026-09-30. Alcance neutral autorizado por la continuación del
usuario y el handoff del día. Se releyeron las cuatro guías obligatorias
de `AGENTS.md`, Morfo, Eidos, tipos, receta, README, `ImageProvider` y
el antecedente Air en `morfo-runtime`. `web/` queda fuera del alcance
congelado. La [ficha de familia](./avatar-image.md) conserva el contexto
de Image y Q15–Q16.
| Aspecto | [Radix Primitives Avatar](https://www.radix-ui.com/primitives/docs/components/avatar) | [Bits UI Avatar](https://bits-ui.com/docs/components/avatar) | [Chakra Avatar](https://chakra-ui.com/docs/components/avatar) | [Mantine Avatar](https://mantine.dev/core/avatar/) | Decisión UIX |
| ------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------- |
| Ciclo de imagen | Root, Image y Fallback; estado de carga | Root coordina status y bypass opcional | Image y Fallback compuestos | Placeholder ante fallo o ausencia | Conservar `ImageProvider` compartido y partes explícitas. |
| Demora | `delayMs` en Fallback | `delayMs` en Root | Composición de Root/Fallback | Sin demora en el uso básico | Medir demora del root y diferir Q14 en la part. |
| Presentación | Headless | Headless | Tamaños, variantes, forma, paleta | Tamaños, radio, variante, color | Conservar seis tamaños, `MarkerVariant`, radio y color completo. |
| Colección | Composición del consumidor | Composición del consumidor | Grupo con stacking y overflow | Grupo apilado | Conservar `AvatarGroup` explícito y medir su tope. |
| Estado superpuesto | Composición del consumidor | Composición del consumidor | Badge de estado | Composición del consumidor | Conservar `Avatar.Badge` decorativo. |
**Arquitectura y antecedente.** Morfo limita Avatar a Eidos y declara
`data-status` cruzando hacia el `ImageProvider` de Soma. No hay Soma
de componente ni eventos Sema propios: cargar un recurso no es una
activación del usuario. Eidos posee root, Image, Fallback, Badge y
AvatarGroup. Air sí existe en `morfo-runtime:src/uix/air/components/avatar/`:
usaba root `div`, precarga con `new Image()` y montaba `<img>` sólo
al cargar. La implementación actual usa `span` e `ImageProvider`.
No hay una extracción adicional que reduzca código sin confundir la
separación ya existente entre ciclo y pintura.
**Baseline nativa.** En Chromium se ejecutaron 21 casos: 19 positivos,
dos `AssertionError` de Q14 y cero pendientes. Los 19 positivos
promovidos conservan exactamente nombres, estados y diagnósticos de
la baseline; tests y dos helpers están fuera del wrapper. Miden root
pasivo y fallback, `solid`/`outline`, tallas `xs`/`xxl`, radio,
color de paleta y CSS, anillo, Badge dot/contador, atributos y
estilo, reactividad, demora del root, load/error reales de imágenes
data URL, grupo plano y apilado con overflow, herencia y override
de talla y badge en RTL. Los 21 PNG originales, incluidas ambas
sondas Q14, fueron inspeccionados individualmente y archivados con
SHA-256 en `tmp/avatar-audit-evidence-2026-09-30/`.
**Q14, diferido.** `Avatar.Fallback delayMs={10000}` quedó visible
durante `loading` cuando el root no demoraba; la prop de la part
se desestructura sin llegar al provider. El control positivo con
`Avatar delayMs={10000}` sí ocultó el fallback. Tras cargar una imagen
válida, el callback del root recibió `loading` y `loaded`, mientras
`Avatar.Image.onImageStatusChange` no recibió `loaded` dentro del root.
Las dos sondas negativas se retiraron del suite permanente; la
corrección requiere proteger las variantes anidada e independiente.
La captura de un grupo `xl` con un hijo `sm` muestra un solape visual
fuerte: el margen de superposición sigue la talla del grupo mientras
el hijo conserva su override. Queda como límite de composición para
adjudicación visual, sin inferir un cambio de contrato de esta captura.
Se corrigieron sólo README e `index.ts` para describir Air, Q14, las
variantes reales y la propiedad de `ImageProvider`. Wrappers, tipos,
CSS, receta y API ejecutable intactos; cero lógica extraída.
`component:audit --only avatar` dio PASS 1/cero errores/avisos.
La primera gate se detuvo en Prettier del README; tras formatearlo,
la gate integral salió exit 0: 597 archivos/7244 pruebas, docs 972/
arts 25 limpios, `check:gate` 89 históricos/cero nuevos en src/scripts,
apps/base check/build y smoke foundation 1/1 + boot 27/27. Avatar
queda reviewed/censo 158; fase 6 abierta. Estas sondas no certifican
AT, touch, tema oscuro, contraste, otros motores ni paridad externa.

@ -349,7 +349,7 @@ Q195, conciliación reproducida de D-TH.5. Ambos quedan para después de la audi
| Q11 | Eidos canvas-text | `FontFace` tardía: WebKit pintó 5 líneas a 124 px y `layout()` contó 4 tras invalidar; Chromium y Firefox coincidieron | Harness parcial intermitente; `<SText>` real pasa en tres motores | Aislar sólo si reaparece en otro consumidor; no modificar el runtime |
| Q12 | Badge API | `BadgeProps` hereda `BoxProps`, pero `badge.svelte` conserva `<span>` con `as="section"`, sigue montado con `visibleFrom="xl"` a 350 px y calcula 10 px de padding de receta frente a `padding="20px"` | Tres `AssertionError` reproducidos en Vitest client (Chromium); sondas retiradas | Decidir si estrechar el tipo o implementar las props; proteger compatibilidad antes de corregir |
| Q13 | Spinner/Skeleton API | Ambos tipos heredan `BoxProps` y afirman componer Box; los wrappers renderizan `<div>` y reenvían `as`, visibilidad y medidas mediante `...rest`. Skeleton anuncia `width`/`height` en su ejemplo, mientras su CSS fija `inline-size:100%` y `block-size:var(--skeleton-size)` | Discrepancia confirmada por lectura; geometría DOM aún no medida | Medir atributos y tamaño en cliente; decidir compatibilidad de props al cerrar la auditoría |
| Q14 | Avatar parts | `Avatar.Fallback` desestructura `delayMs` sin usarlo; `Avatar.Image` sólo conecta `onImageStatusChange` al provider cuando no hay root padre | Reproducido en Vitest client (Chromium) | Probar las dos props dentro de un Avatar antes de corregir su cableado |
| Q14 | Avatar parts | Con `loading`, `Fallback delayMs={10000}` aparece de inmediato mientras la demora del root sí opera; tras `loaded`, el root recibe callback y `Avatar.Image` anidada no lo recibe | Dos `AssertionError` en Vitest client (Chromium); sondas retiradas | Adjudicar y corregir ambas props con casos anidado e independiente |
| Q15 | Image.Img | `ImageImgProps` promete heredar `src` y `alt` del root, pero la part fija defaults `null` y `''` en su `<img>` sin leer los props del padre | Reproducido en Vitest client (Chromium) | Probar herencia y precedencia de valores explícitos; cubrir carga y SSR |
| Q16 | Image error | El README promete usar Fallback si no hay Error, pero la ruta abreviada oculta Fallback en `error` y monta un slot Error sin contenido si falta `errorFallback` | Reproducido en Vitest client (Chromium) | Probar error con `fallback` solo, `errorFallback`, slot explícito y `fallbackSrc` |
| Q17 | Code Morfo | `codeMorfo` declara `defaultElement: 'span'`; el wrapper Eidos, su tipo y README establecen `code` por defecto | Discrepancia estática confirmada; no se midió efecto en consumidores | Inspeccionar consumidores de metadatos y alinear el elemento declarado sin cambiar el render |
@ -2935,3 +2935,25 @@ PASS 1/cero errores/avisos. Gate integral exit 0: 596 archivos/
7225 pruebas, docs 971/arts 25, `check:gate` 89 históricos/cero
nuevos src/scripts y apps/base check/build/smoke foundation 1/1 +
boot 27/27.
## Avatar — baseline nativa neutral
19 canarios positivos, dos `AssertionError` Q14 y cero pendientes;
21 nombres únicos. La promoción conservó nombres, estados y
diagnósticos positivos exactos; dos helpers y los tests viven fuera
del wrapper. Veintiún PNG originales inspeccionados y archivados/SHA,
incluidos los dos negativos y un estado RTL. [Ficha](./avatar-native.md)
reviewed/censo 158.
Q14 se confirmó en Chromium con controles positivos: la demora del
root oculta el fallback durante `loading`, pero la prop de
`Avatar.Fallback` no; el callback del root recibe `loaded`, pero el
de `Avatar.Image` anidada no. Las dos sondas negativas se retiraron.
El solape fuerte con grupo `xl` e hijo `sm` se documenta como límite
de composición pendiente de adjudicación visual. README y comentario
de `index.ts` conciliados; wrappers, tipos, CSS, receta y API ejecutable
intactos. `component:audit --only avatar` PASS 1/cero errores/avisos.
Primera gate detenida por formato del README; segunda gate integral
exit 0: 597 archivos/7244 pruebas, docs 972/arts 25,
`check:gate` 89 históricos/cero nuevos src/scripts y apps/base
check/build/smoke foundation 1/1 + boot 27/27.

@ -1,8 +1,9 @@
# Eidos Avatar
Avatar no existe en la rama Air anterior (`glm-5:src/uix/air/components`), asi
que el componente parte del contrato Eidos/Morfo actual y se compara con Radix,
Bits UI, Ark/Reka y shadcn-svelte.
Avatar sí tiene antecedente Air en `morfo-runtime:src/uix/air/components/avatar/`:
root `div`, precarga de imagen y fallback compuesto. La implementación actual
parte del contrato Eidos/Morfo y comparte `ImageProvider` de Soma. La
continuación nativa contrasta Radix, Bits UI, Chakra y Mantine.
## Superficie
@ -52,7 +53,7 @@ dejaría que un tema los fijara y matara el `color=` de cada avatar.
| Compound API | Si: `<Avatar.Image>` / `<Avatar.Fallback>` | Si | Si | Si | Si |
| Fallback por error | Si | Si | Si | Si | Si |
| Estado de imagen observable | Si: `imageStatus` / `onImageStatusChange` | Si, en Image | Si | Si | Via Bits |
| `delayMs` para fallback | Si, root o fallback | Si, Fallback | Si | Si | Si |
| `delayMs` para fallback | Root sí; part aceptada sin efecto (Q14) | Si, Fallback | Si | Si | Si |
| Bypass de carga | Si: `imageStatus="loaded"` | No directo | Si | Si | Via Bits |
| `data-status` | Si, root/image/fallback | Si | Si | Si | Si |
| Variantes visuales | Si: solid/soft/outline | No, unstyled | No, unstyled | No, unstyled | Tailwind local |
@ -66,12 +67,14 @@ dejaría que un tema los fijara y matara el `color=` de cada avatar.
- El comportamiento de imagen vive en `ImageProvider` (`$soma/layers`), no en
Avatar. El root crea esa instancia, `Image` registra su `src` y actualiza
`loading`, `loaded` y `error`; `Fallback` solo consume `fallbackVisible`.
- `delayMs` puede vivir en el root para cubrir el caso comun, o en
`Avatar.Fallback` para ajustar una instancia concreta.
- `delayMs` del root funciona durante `loading`. La prop homónima de
`Avatar.Fallback` figura en el tipo pero aún no llega al provider (Q14).
- El bypass `imageStatus="loaded"` existe para iconos locales o imagenes ya
cacheadas donde el consumer no quiere esperar al evento `load`.
- El fallback se renderiza en `idle` y `error` sin demora; la demora solo aplica
al estado `loading`.
- `Avatar.Image.onImageStatusChange` aún no recibe transiciones cuando la part
está anidada en `<Avatar>`; sólo el callback del root las recibe (Q14).
## Eventos Sema
@ -90,20 +93,19 @@ menu o perfil interactivo, el evento pertenece a ese componente contenedor.
## Baseline
Referencia local: Soma + Morfo + Eidos. La capa visual de Eidos
expone size / variant / color sobre el comportamiento que Soma ya
implementa; el morfo declara parts, ARIA, keyboard y eventos sema.
Las decisiones se alinean con las bibliotecas externas listadas en
la Comparativa.
Referencia local: Morfo declara las parts y `data-status`; el wrapper
Eidos compone `ImageProvider` de Soma para el ciclo de recurso y añade
size / variant / color. Avatar no declara teclado ni eventos Sema.
Las decisiones se contrastan con las bibliotecas de la Comparativa.
## Gaps
| Gap | Disposición | Detalle |
| --------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------- |
| API extendida sobre las referencias externas | **diferir** | Sólo si llega un caso de uso real. Mantener la superficie estable. |
| Cobertura adicional de variantes visuales | **diferir** | El recipe cubre sm/md/lg + solid/outline/ghost. Más variantes requieren caso concreto. |
| Documentación per-prop exhaustiva | **implementar** | Cuando se cierre el ciclo de remediación de cada componente. |
| Tests browser-level del flujo completo (Playwright) | **implementar** | Cobertura visual + interacciones. Se hace en una pasada conjunta de tests. |
| Gap | Disposición | Detalle |
| --------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------- |
| API extendida sobre las referencias externas | **diferir** | Sólo si llega un caso de uso real. Mantener la superficie estable. |
| Cobertura adicional de variantes visuales | **diferir** | El recipe cubre xs–xxl + solid/soft/outline. Más variantes requieren caso concreto. |
| Documentación per-prop exhaustiva | **implementar** | Cuando se cierre el ciclo de remediación de cada componente. |
| Tests browser-level del flujo completo (Playwright) | **implementar** | Cobertura visual + interacciones. Se hace en una pasada conjunta de tests. |
## Passive justification

@ -0,0 +1,225 @@
import { expect, it, vi } from 'vitest';
import { visit } from '../../../test/avatar/avatar-audit-view';
const validImage =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="16" height="16"%3E%3Crect width="16" height="16" fill="red"/%3E%3C/svg%3E';
it('native default Avatar is a passive span with visible fallback', () =>
visit(async (view) => {
const root = view.root();
expect(root.tagName).toBe('SPAN');
expect(root.getAttribute('data-status')).toBe('idle');
expect(root.getAttribute('data-size')).toBe('md');
expect(root.getAttribute('data-variant')).toBe('soft');
expect(root.getAttribute('data-color')).toBe('neutral');
expect(view.fallback()?.textContent).toBe('AL');
expect(view.image()).toBeNull();
await view.capture('default');
}));
it('native solid avatar resolves a colored fill', () =>
visit(
async (view) => {
expect(view.root().getAttribute('data-variant')).toBe('solid');
expect(view.root().getAttribute('data-color')).toBe('teal');
expect(getComputedStyle(view.root()).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
await view.capture('solid');
},
{ variant: 'solid', color: 'teal' }
));
it('native outline avatar paints a border and square radius', () =>
visit(
async (view) => {
const css = getComputedStyle(view.root());
expect(view.root().getAttribute('data-radius')).toBe('none');
expect(css.borderStyle).toBe('solid');
expect(css.borderRadius).toBe('0px');
await view.capture('outline-square');
},
{ variant: 'outline', radius: 'none', color: 'risk' }
));
it('native xs avatar resolves the small size token', () =>
visit(
async (view) => {
expect(view.root().getAttribute('data-size')).toBe('xs');
expect(view.root().getBoundingClientRect().width).toBeGreaterThan(0);
await view.capture('xs');
},
{ size: 'xs' }
));
it('native xxl avatar resolves a larger visual scale', () =>
visit(
async (view) => {
expect(view.root().getAttribute('data-size')).toBe('xxl');
expect(view.root().getBoundingClientRect().width).toBeGreaterThan(60);
await view.capture('xxl');
},
{ size: 'xxl' }
));
it('native solid ring and width decorate the same root', () =>
visit(
async (view) => {
expect(view.root().getAttribute('data-ring')).toBe('solid');
expect(view.root().getAttribute('data-ring-width')).toBe('lg');
expect(getComputedStyle(view.root()).boxShadow).not.toBe('none');
await view.capture('ring-solid');
},
{ ring: true, ringWidth: 'lg', ringColor: 'affirm' }
));
it('native soft ring accepts a custom ring color', () =>
visit(
async (view) => {
expect(view.root().getAttribute('data-ring')).toBe('soft');
expect(view.root().hasAttribute('data-ring-color-custom')).toBe(true);
expect(view.root().style.getPropertyValue('--_avatar-ring-color-custom').trim()).toBe(
'#123456'
);
await view.capture('ring-soft');
},
{ ring: 'soft', ringColor: '#123456' }
));
it('native raw CSS color uses the custom palette bridge', () =>
visit(
async (view) => {
expect(view.root().hasAttribute('data-color-custom')).toBe(true);
expect(view.root().style.getPropertyValue('--color-custom').trim()).toBe('#123456');
await view.capture('custom-color');
},
{ color: '#123456', variant: 'solid' }
));
it('native dot badge is decorative and anchored to a corner', () =>
visit(
async (view) => {
expect(view.badge()?.getAttribute('data-dot')).toBe('');
expect(view.badge()?.getAttribute('aria-hidden')).toBe('true');
expect(view.badge()?.getAttribute('data-position')).toBe('top-end');
await view.capture('badge-dot');
},
{ badge: 'dot', badgePosition: 'top-end' }
));
it('native counter badge keeps text and consumer color', () =>
visit(
async (view) => {
expect(view.badge()?.textContent).toBe('9+');
expect(view.badge()?.getAttribute('data-color')).toBe('risk');
expect(view.badge()?.hasAttribute('data-dot')).toBe(false);
await view.capture('badge-count');
},
{ badge: 'count' }
));
it('native consumer attributes and style reach the Avatar root', () =>
visit(
async (view) => {
expect(view.root().classList.contains('audit-avatar')).toBe(true);
expect(view.root().getAttribute('data-probe')).toBe('avatar');
expect(view.root().getAttribute('title')).toBe('Audit Avatar');
expect(getComputedStyle(view.root()).letterSpacing).toBe('2px');
await view.capture('style');
},
{ style: 'letter-spacing:2px' }
));
it('native reactive size variant and color update the same root', () =>
visit(async (view) => {
const root = view.root();
view.controls().size('lg');
view.controls().variant('solid');
view.controls().color('risk');
await view.ready();
expect(view.root()).toBe(root);
expect(root.getAttribute('data-size')).toBe('lg');
expect(root.getAttribute('data-variant')).toBe('solid');
expect(root.getAttribute('data-color')).toBe('risk');
await view.capture('reactive');
}));
it('native root delay hides fallback while loading', () =>
visit(
async (view) => {
view.controls().status('loading');
await view.ready();
expect(view.root().getAttribute('data-status')).toBe('loading');
expect(view.fallback()).toBeNull();
await view.capture('root-delay');
},
{ rootDelay: 10000 }
));
it('native image load exposes img alt and root status callback', () =>
visit(
async (view) => {
await vi.waitFor(() => expect(view.root().getAttribute('data-status')).toBe('loaded'));
expect(view.image()?.getAttribute('alt')).toBe('Ada Lovelace');
expect(view.fallback()).toBeNull();
expect(view.events).toContain('root:loaded');
await view.capture('loaded');
},
{ includeImage: true, src: validImage }
));
it('native image error reveals the fallback and root status', () =>
visit(
async (view) => {
await vi.waitFor(() => expect(view.root().getAttribute('data-status')).toBe('error'));
expect(view.image()).toBeNull();
expect(view.fallback()?.textContent).toBe('AL');
await view.capture('error');
},
{ includeImage: true, src: 'data:image/png;base64,broken' }
));
it('native side by side group passes defaults while child override wins', () =>
visit(
async (view) => {
expect(view.group().getAttribute('data-stacking')).toBe('side-by-side');
expect(view.member(1).getAttribute('data-size')).toBe('lg');
expect(view.member(2).getAttribute('data-size')).toBe('sm');
expect(view.member(3).getAttribute('data-variant')).toBe('outline');
expect(getComputedStyle(view.group()).gap).not.toBe('0px');
await view.capture('group-flat');
},
{ group: true, size: 'lg', variant: 'outline', stacking: 'side-by-side', gap: 'md' }
));
it('native overlapping group caps visible children and shows overflow', () =>
visit(
async (view) => {
expect(view.group().getAttribute('data-has-max')).toBe('2');
expect(getComputedStyle(view.member(3)).display).toBe('none');
expect(view.group().querySelector('[data-avatar-overflow]')?.textContent?.trim()).toBe('+3');
await view.capture('group-overflow');
},
{ group: true, max: 2, total: 5 }
));
it('native group defaults remain reactive for nonoverridden children', () =>
visit(
async (view) => {
view.controls().groupSize('xl');
await view.ready();
expect(view.member(1).getAttribute('data-size')).toBe('xl');
expect(view.member(2).getAttribute('data-size')).toBe('sm');
await view.capture('group-reactive');
},
{ group: true, size: 'md' }
));
it('native RTL positions an end badge at the left inline edge', () =>
visit(
async (view) => {
expect(view.badge()!.getBoundingClientRect().left).toBeLessThan(
view.root().getBoundingClientRect().left + view.root().getBoundingClientRect().width / 2
);
await view.capture('rtl-badge');
},
{ badge: 'dot', badgePosition: 'bottom-end', dir: 'rtl' }
));

@ -16,8 +16,8 @@
// </AvatarGroup>
//
// `<Avatar>` IS the root (lives in avatar.svelte). Eidos-native — no
// headless backing for avatar; the recipe + image-load fallback live
// entirely here.
// headless component backing for avatar; the recipe lives here while
// the shared Soma ImageProvider owns image load and fallback state.
import AvatarComponent from './avatar.svelte';
import Image from './avatar-image.svelte';
import Fallback from './avatar-fallback.svelte';

@ -0,0 +1,136 @@
<script module lang="ts">
import type { AvatarProps, AvatarGroupProps, BadgeProps } from '$uix/eidos/components/avatar';
export type Config = {
size?: AvatarProps['size'];
variant?: AvatarProps['variant'];
color?: AvatarProps['color'];
radius?: AvatarProps['radius'];
ring?: AvatarProps['ring'];
ringColor?: AvatarProps['ringColor'];
ringWidth?: AvatarProps['ringWidth'];
rootDelay?: number;
fallbackDelay?: number;
includeImage?: boolean;
src?: string;
badge?: 'dot' | 'count';
badgePosition?: BadgeProps['position'];
group?: boolean;
stacking?: AvatarGroupProps['stacking'];
max?: number;
total?: number;
gap?: AvatarGroupProps['gap'];
dir?: 'ltr' | 'rtl';
style?: string;
};
export type Controls = {
size: (value: AvatarProps['size']) => void;
variant: (value: AvatarProps['variant']) => void;
color: (value: AvatarProps['color']) => void;
status: (value: NonNullable<AvatarProps['imageStatus']>) => void;
src: (value: string) => void;
groupSize: (value: AvatarProps['size']) => void;
statusValue: () => AvatarProps['imageStatus'];
};
</script>
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { setActiveUix, type ActiveUix } from '$active-uix';
import { Soma } from '$soma/core/soma.svelte';
import { ActiveEidos } from '$uix/eidos';
import { Avatar, AvatarGroup } from '$uix/eidos/components/avatar';
let {
uix,
initial = {},
onControls,
onEvent
}: {
uix: ActiveUix;
initial?: Config;
onControls: (controls: Controls) => void;
onEvent: (event: string) => void;
} = $props();
// svelte-ignore state_referenced_locally
setActiveUix(uix);
Soma.create();
const eidos = ActiveEidos.create({ applyDom: false });
onDestroy(() => eidos.dispose());
// svelte-ignore state_referenced_locally
let size = $state(initial.size);
// svelte-ignore state_referenced_locally
let variant = $state(initial.variant);
// svelte-ignore state_referenced_locally
let color = $state(initial.color);
let status = $state<NonNullable<AvatarProps['imageStatus']>>('idle');
// svelte-ignore state_referenced_locally
let src = $state(initial.src ?? '');
// svelte-ignore state_referenced_locally
let groupSize = $state(initial.size);
onMount(() =>
onControls({
size: (value) => (size = value),
variant: (value) => (variant = value),
color: (value) => (color = value),
status: (value) => (status = value),
src: (value) => (src = value),
groupSize: (value) => (groupSize = value),
statusValue: () => status
})
);
</script>
<div
id="audit-avatar-stage"
dir={initial.dir ?? 'ltr'}
style="padding:32px;font:16px Arial,sans-serif"
>
{#if initial.group}
<AvatarGroup
id="audit-group"
size={groupSize}
{variant}
radius={initial.radius}
stacking={initial.stacking}
max={initial.max}
total={initial.total}
gap={initial.gap}
>
<Avatar id="audit-member-1"><Avatar.Fallback>AL</Avatar.Fallback></Avatar>
<Avatar id="audit-member-2" size="sm"><Avatar.Fallback>BO</Avatar.Fallback></Avatar>
<Avatar id="audit-member-3"><Avatar.Fallback>CY</Avatar.Fallback></Avatar>
<Avatar id="audit-member-4"><Avatar.Fallback>DI</Avatar.Fallback></Avatar>
</AvatarGroup>
{:else}
<Avatar
id="audit-avatar"
class="audit-avatar"
data-probe="avatar"
title="Audit Avatar"
{size}
{variant}
{color}
radius={initial.radius}
ring={initial.ring}
ringColor={initial.ringColor}
ringWidth={initial.ringWidth}
bind:imageStatus={status}
delayMs={initial.rootDelay}
onImageStatusChange={(value) => onEvent('root:' + value)}
style={initial.style}
>
{#if initial.includeImage}
<Avatar.Image
{src}
alt="Ada Lovelace"
onImageStatusChange={(value) => onEvent('image:' + value)}
/>
{/if}
<Avatar.Fallback delayMs={initial.fallbackDelay}>AL</Avatar.Fallback>
{#if initial.badge === 'dot'}
<Avatar.Badge dot position={initial.badgePosition} color="affirm" />
{:else if initial.badge === 'count'}
<Avatar.Badge position={initial.badgePosition} color="risk">9+</Avatar.Badge>
{/if}
</Avatar>
{/if}
</div>

@ -0,0 +1,66 @@
import { mount, unmount, flushSync } from 'svelte';
import { page } from 'vitest/browser';
import { createActiveUix } from '$active-uix';
import '$uix/eidos/index.css';
import Harness, { type Config, type Controls } from './avatar-audit-harness.svelte';
export function renderAvatar(initial: Config = {}) {
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: 'en' },
prefs: { storage: false },
projectPrefs: false
});
const target = document.createElement('div');
document.body.append(target);
let controls!: Controls;
const events: string[] = [];
const component = mount(Harness, {
target,
props: {
uix,
initial,
onControls: (value) => (controls = value),
onEvent: (event) => events.push(event)
}
});
flushSync();
const ready = async () =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
return {
initial,
target,
events,
controls: () => controls,
root: () => target.querySelector<HTMLElement>('#audit-avatar')!,
fallback: () => target.querySelector<HTMLElement>('[data-avatar-fallback]'),
image: () => target.querySelector<HTMLImageElement>('[data-avatar-image]'),
badge: () => target.querySelector<HTMLElement>('[data-avatar-badge]'),
group: () => target.querySelector<HTMLElement>('#audit-group')!,
member: (index: number) => target.querySelector<HTMLElement>('#audit-member-' + index)!,
ready,
async capture(name: string) {
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
await ready();
await page.screenshot({ path: 'tmp/avatar-' + name + '.png' });
}
},
async dispose() {
await unmount(component);
uix.dispose();
target.remove();
}
};
}
export type View = ReturnType<typeof renderAvatar>;
export async function visit(fn: (view: View) => Promise<void>, initial: Config = {}) {
await page.viewport(1100, 900);
const view = renderAvatar(initial);
try {
await view.ready();
await fn(view);
} finally {
await view.dispose();
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.