test(uix): audit EmptyState native behavior

astra
dev 6 days ago
parent e60e079a26
commit 153b5fc17d

@ -10,7 +10,8 @@ status: reviewed
Workspace G:\dev\svelte\vicen, rama astra, destino autorizado gita/astra.
El cierre previo terminó tras Meter y Banner. El usuario reanudó la auditoría
el 1 de octubre; Callout cerró su baseline nativa con hallazgos diferidos.
el 1 de octubre; Callout y EmptyState cerraron sus baselines nativas con
hallazgos diferidos. El usuario autorizó continuar hasta que indique parar.
Al retomar comprobar HEAD,
gita/astra y estado local; no depender de un hash autorreferente en este fichero.
@ -22,11 +23,12 @@ Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
## Últimos trabajos
| Componente | Ficha/censo | Baseline | Evidencia | Hallazgos |
| ---------- | -------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------- | --------------------------- |
| Meter | [reviewed/163](./audit-uix-2026-09-19/meter-native.md) | 32 positivos, seis AssertionError, 38 únicos, cero pendientes | 38 originales/SHA, uno a 350px y uno RTL | L-152, Q315, Q317 diferidos |
| Banner | [reviewed/164](./audit-uix-2026-09-19/banner-native.md) | 35 positivos, cinco AssertionError, 40 únicos, cero pendientes | 40 originales/SHA, uno a 350px y uno RTL | L-152, Q318 diferidos |
| Callout | [reviewed/165](./audit-uix-2026-09-19/callout-native.md) | 32 positivos, siete AssertionError, 39 únicos, cero pendientes | 39 originales/SHA, uno a 350px y uno RTL | Q319–Q322, Q313 diferidos |
| Componente | Ficha/censo | Baseline | Evidencia | Hallazgos |
| ---------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------- | --------------------------- |
| Meter | [reviewed/163](./audit-uix-2026-09-19/meter-native.md) | 32 positivos, seis AssertionError, 38 únicos, cero pendientes | 38 originales/SHA, uno a 350px y uno RTL | L-152, Q315, Q317 diferidos |
| Banner | [reviewed/164](./audit-uix-2026-09-19/banner-native.md) | 35 positivos, cinco AssertionError, 40 únicos, cero pendientes | 40 originales/SHA, uno a 350px y uno RTL | L-152, Q318 diferidos |
| Callout | [reviewed/165](./audit-uix-2026-09-19/callout-native.md) | 32 positivos, siete AssertionError, 39 únicos, cero pendientes | 39 originales/SHA, uno a 350px y uno RTL | Q319–Q322, Q313 diferidos |
| EmptyState | [reviewed/166](./audit-uix-2026-09-19/empty-state-native.md) | 33 positivos, dos AssertionError, 35 únicos, cero pendientes; sonda ARIA adicional | 35 originales/SHA, uno a 350px y uno RTL | Q323/Q324 diferidos |
Meter quedó en a781bed7e. Su gate pasó 602 archivos/7383 pruebas, docs 978,
arts 25, check:gate 89 históricos/cero nuevos src/scripts y apps/base
@ -63,10 +65,12 @@ triado. Censo 165 reviewed; gate integral exit 0: 604 archivos/7450 pruebas,
docs 981/arts 25, check:gate 89 históricos/cero nuevos src/scripts y apps/base
check/build/smoke foundation 1/1 + boot 27/27, sin pageerror, console.error ni CSP.
## Siguiente: EmptyState
## Siguiente: Result
Retomar [EmptyState/Result](./audit-uix-2026-09-19/empty-state-result.md).
Sin baseline nueva ni cambios de esta sesión. Releer completas las cuatro guías
EmptyState tiene baseline 33/2, 35 originales/SHA, dirigida 65/65 y Q323/Q324
diferidos; censo 166 reviewed y gate integral exit 0, 605 archivos/7483 pruebas. Result aún sin baseline nueva.
Releer completas las cuatro guías
obligatorias, terminar fuentes/receta/Langs/Air y referencias antes de escribir
la baseline. Mantener roles y acciones compuestas en su propietario actual.
@ -78,14 +82,31 @@ la baseline. Mantener roles y acciones compuestas en su propietario actual.
- .claude/settings.local.json y .vitest-attachments/ son cambios del usuario
y permanecen fuera de estos commits.
- Evidencia ignorada de los últimos lotes:
tmp/{progress,meter,banner,callout}-audit-evidence-2026-10-01/.
tmp/{progress,meter,banner,callout,empty-state}-audit-evidence-2026-10-01/.
Los handoffs previos enumeran el resto.
- Archivo de imágenes, promociones y cierres ya ejecutados son mutaciones
no idempotentes: no repetir promote-neutral.mjs, reconcile-docs.mjs,
close-progress.mjs, close-meter.mjs ni los cierres de Banner/Callout. Los scripts
close-progress.mjs, close-meter.mjs ni los cierres de Banner/Callout/EmptyState. Los scripts
verify-neutral.mjs sí se pueden repetir como lectura/diagnóstico.
- Para gate/Vitest, TEMP y TMP a node_modules/.cache y capturar LASTEXITCODE
inmediatamente. No mutar archivos durante una gate viva.
- Logs de gate y push del cierre previo están en la carpeta de evidencia
de Banner; los de esta reanudación, en la de Callout. Comprobar el código final; avisos de parser de fixtures negativos
de Banner; los de esta reanudación, en las de Callout y EmptyState. Comprobar el código final; avisos de parser de fixtures negativos
no son por sí solos fallo de gate.
### EmptyState — cierre neutral (2026-10-01)
Baseline única: 33 positivos, dos AssertionError, cero pendientes, 35 nombres
únicos. Una sonda adicional confirma role/aria-label null. Dirigida
EmptyState/Callout 65/65 y huella exacta. Los 35 originales se inspeccionaron
individualmente y archivaron/SHA: 34 a 1100×900, uno a 350×900 y uno RTL.
Sin calibración, freezes ni regenerar originales. Q323 y Q324 diferidos;
negativas y sonda adicional sólo en tmp. Sólo README conciliado; 16 fuentes
previas intactas, API/CSS/receta conservadas y cero extracción. Guard PASS/cero
errores; aviso D-1.2 de tabs congeladas triado. Gate integral exit 0: 605 archivos/7483 pruebas, docs 982/arts 25.
Censo 166 reviewed; check:gate 89 históricos/cero nuevos src/scripts, apps/base
check/build/smoke foundation 1/1 + boot 27/27.
Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
[Ficha](./audit-uix-2026-09-19/empty-state-native.md). El usuario autorizó continuar
sin parar hasta nueva instrucción. No cerrar la fase 6 global por cerrar este lote.

@ -4232,3 +4232,19 @@ cero nuevos src/scripts y apps/base check/build/smoke foundation 1/1 + boot 27/2
Censo 165 reviewed. Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
[Ficha](./audit-uix-2026-09-19/callout-native.md). EmptyState es el siguiente
candidato; no tiene baseline nueva ni cambios de esta sesión.
### EmptyState — cierre neutral (2026-10-01)
Baseline única: 33 positivos, dos AssertionError, cero pendientes, 35 nombres
únicos. Una sonda adicional confirma role/aria-label null. Dirigida
EmptyState/Callout 65/65 y huella exacta. Los 35 originales se inspeccionaron
individualmente y archivaron/SHA: 34 a 1100×900, uno a 350×900 y uno RTL.
Sin calibración, freezes ni regenerar originales. Q323 y Q324 diferidos;
negativas y sonda adicional sólo en tmp. Sólo README conciliado; 16 fuentes
previas intactas, API/CSS/receta conservadas y cero extracción. Guard PASS/cero
errores; aviso D-1.2 de tabs congeladas triado. Gate integral exit 0: 605 archivos/7483 pruebas, docs 982/arts 25.
Censo 166 reviewed; check:gate 89 históricos/cero nuevos src/scripts, apps/base
check/build/smoke foundation 1/1 + boot 27/27.
Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
[Ficha](./audit-uix-2026-09-19/empty-state-native.md). Result continúa después.

@ -0,0 +1,86 @@
---
title: UIX — EmptyState, ausencia pasiva y acciones compuestas
type: notes
audience: human + agent
authority: process — auditoría neutral, no contrato nuevo
status: reviewed
---
# EmptyState — continuación nativa
Fecha: 2026-10-01. Alcance neutral autorizado por «continua» y «sigue, no pares
hasta que te lo indique». Las cuatro guías obligatorias se leyeron completas
antes de escribir la baseline. Se revisaron los cinco wrappers, tipos/index/
README/CSS, Morfo, Langs, receta/base generado, resolución responsiva de
ActiveEidos/ActiveDom y el Button compuesto. La
[ficha de familia](./empty-state-result.md) conserva la revisión estática previa.
| Aspecto | [shadcn Empty](https://ui.shadcn.com/docs/components/base/empty) | [Chakra Empty State](https://chakra-ui.com/docs/components/empty-state) | [Atlassian Empty state](https://atlassian.design/components/empty-state/examples) | [AntD Empty](https://ant.design/components/empty/) | Decisión UIX |
| -------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------- |
| Anatomía | Root/Header/Media/Title/Description/Content | Root/Content/Indicator/Title/Description | Header, descripción, media y acciones | Imagen, descripción y footer | Conservar root y cuatro partes explícitas |
| Densidad | Utilidades de presentación | sm/md/lg | Ancho contextual | Imagen simple/default | Conservar talla y respuesta al viewport |
| Media | Icono o slot libre/avatar | Indicator consumidor | Imagen con sizing | Imagen y estilos custom | Conservar icon/media; contención grande diferida |
| Semántica | Contenido compuesto | Title y acciones | Heading configurable, h4 default | Contenido consumidor | h3 default, nivel semántico independiente y grupo opcional |
| Comportamiento | Acciones consumidoras | Botones compuestos | Acciones primaria/secundaria | Inyección global de empty | Colección posee anuncios; Button posee activación |
No existe EmptyState en `morfo-runtime:src/uix/air`. Morfo es pasivo,
`scope: ['eidos']`, cero eventos propios, sin teclado, provider Soma ni pack
Sema. La colección posee anuncios; el consumidor suministra copy/media/acciones
y desmontaje. Langs declara un label, pero estos wrappers no lo consumen ni
generan contenido automático. Los tipos no exponen ref bindable: no se adjudica
L-152. El root sí conserva ARIA explícita. No se extrae un wrapper común con
Result: densidad/talla frente a status/context/Extra y recetas independientes
siguen justificando el esqueleto.
Baseline única Chromium: **33 positivos, dos AssertionError, cero pendientes y
35 nombres únicos**. Sin calibración ni edición de aserciones. Fuentes, JSON/log
y negativas quedan en `tmp/empty-state-audit-evidence-2026-10-01/`. Una sonda
adicional independiente da un AssertionError y acredita role/aria-label null
frente al control HTML válido; no modifica la baseline ni genera otra captura
manual. Los 33 positivos se promueven con dos helpers fuera de los wrappers;
se retira sólo instrumentación exclusiva de las negativas.
Los positivos miden default pasivo/centrado, tres tallas, placa proporcional,
cinco niveles heading, mutación de nivel con ID/copy/tamaño conservados, media
libre y alt significativo, cambio icon/media, grupo nombrado/plain y limpieza
reactiva, ARIA explícita del root, IDs/classes/data consumidoras, partes omitidas
y composición vacía, copy/talla reactivos, viewport lg→md→sm→lg con mismo root,
350px, RTL, tokens, Tab con/sin acciones, click/Enter/Space una vez, desmontaje
consumidor, nesting independiente y reduce. Button emite contact-activate;
EmptyState no emite commit ni se desmonta hasta que lo decide el handler.
Los 35 PNG originales fueron inspeccionados individualmente y archivados/SHA:
34 a 1100×900 y uno a 350×900; uno RTL. Sin freezes, regenerar ni sobrescribir.
Placa neutral tenue, título oscuro y botones violeta solid/outline. sm compacta,
md deja respiro y lg aumenta padding/título/media; heading no altera pintura.
Imagen pequeña conserva alt y no recibe placa. A 350px descripción envuelve y
botones se apilan dentro del root. RTL coloca el marco a la derecha y revierte
la fila; el bidi inglés es nativo, sin adjudicar defecto. Tokens amplían título/
gap y reducen medida del párrafo. Enter/Space muestran foco y contador uno;
desmontaje deja sólo controles vecinos. Las imágenes no acreditan por sí solas
nombres accesibles: ese contrato se comprueba con DOM y locators.
| ID | Resultado observado | Disposición |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Q323 | Actions con role=group y aria-label explícitos, sin label, termina con ambos null; control HTML conserva ambos. label sí produce grupo nombrado y se limpia reactivamente. | Diferir precedencia/compatibilidad de atributos frente a label; preservar default plain y grupos válidos. |
| Q324 | Media kind=media con img de 800px mide 800 frente a root de 520px, desborda laterales y recorta el izquierdo en captura. Control max-inline-size:100% queda dentro del root. | Diferir contención en parte o consumidor; README no puede afirmar image clamp propio. Preservar slot libre y alt. |
La guía de [nombres accesibles de APG](https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/)
fundamenta las comprobaciones de role/nombre; no equivale a prueba con AT.
Q324 es un límite geométrico reproducido y una claim documental excesiva;
no autoriza imponer un clamp global al arte consumidor.
Sólo README de producción conciliado. Wrappers/tipos/Morfo/Langs/CSS/receta/API
ejecutable intactos, cero extracción. AT, touch, oscuro, contraste, SSR y otros
motores no certificados. Guard final PASS/cero errores; aviso D-1.2 de tabs
congeladas triado para reconstrucción. Dirigida 65/65, huella exacta, gate integral
exit 0; censo 166 reviewed. Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
## Verificación de cierre
Gate integral exit 0: 605 archivos/7483 pruebas, docs 982/arts 25 sin errores ni
avisos. check:gate 89 históricos/cero nuevos src/scripts; apps/base
check/build/smoke foundation 1/1 + boot 27/27, sin pageerror, console.error ni CSP.
Dirigida 65/65, huella exacta, 35 originales/SHA y 16 fuentes previas intactas.
Guard PASS/cero errores, aviso D-1.2 triado. Censo 166 reviewed; siguiente Result,
sin baseline nueva. Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.

@ -49,3 +49,20 @@ apariencia de cada estado, el nombre del grupo o el nivel de encabezado.
Antes de extraer el esqueleto habría que caracterizar esa salida y la cascada
CSS real. En esta pasada se modifican **0 líneas de producción** y no cambia
ningún contrato ni salida visual.
### EmptyState — cierre neutral (2026-10-01)
Baseline única: 33 positivos, dos AssertionError, cero pendientes, 35 nombres
únicos. Una sonda adicional confirma role/aria-label null. Dirigida
EmptyState/Callout 65/65 y huella exacta. Los 35 originales se inspeccionaron
individualmente y archivaron/SHA: 34 a 1100×900, uno a 350×900 y uno RTL.
Sin calibración, freezes ni regenerar originales. Q323 y Q324 diferidos;
negativas y sonda adicional sólo en tmp. Sólo README conciliado; 16 fuentes
previas intactas, API/CSS/receta conservadas y cero extracción. Guard PASS/cero
errores; aviso D-1.2 de tabs congeladas triado. Gate integral exit 0: 605 archivos/7483 pruebas, docs 982/arts 25.
Censo 166 reviewed; check:gate 89 históricos/cero nuevos src/scripts, apps/base
check/build/smoke foundation 1/1 + boot 27/27.
Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
[Ficha nativa](./empty-state-native.md). La revisión estática anterior queda como
historia; Q323/Q324 añaden evidencia nativa. Result aún no tiene baseline nueva.

@ -65,6 +65,8 @@ conservan huella exacta; 39 originales inspeccionados/SHA. Gate integral exit 0:
| ID público de Title | Q320 | Respetar o restringir id con compatibilidad explícita; comprobar montaje, actualización y registro. |
| Unicidad de Titles | Q321 | Adjudicar múltiples títulos, IDs y registro/cleanup; cubrir peers y anidamiento. |
| Icon propio frente a anidado | Q322 | Limitar selectores a las partes propias y comprobar combinaciones de Icon exterior/interior y RTL. |
| ARIA explícita de Actions | Q323 | Adjudicar precedencia de role/aria-label frente a label; preservar grupos nombrados y default plain, cubrir mutaciones. |
| Contención de media libre | Q324 | Decidir responsabilidad de parte/consumidor y sizing, preservar slots libres y alt; probar ilustraciones grandes y viewport estrecho. |
Q34 se reprodujo con el proveedor Sticky y geometría real en Chromium.
Q35–Q36 se reprodujeron con el wrapper SkipLink montado en Chromium.
@ -3206,3 +3208,24 @@ sí pinta y no amplía Q318. AT/oscuro/contraste/SSR/touch/otros motores no acre
[Ficha](./callout-native.md). Censo 165 reviewed; gate integral exit 0: 604 archivos/7450 pruebas, docs 981/arts 25,
check:gate 89 históricos/cero nuevos src/scripts y apps/base
check/build/smoke foundation 1/1 + boot 27/27.
### EmptyState — cierre neutral (2026-10-01)
Baseline única: 33 positivos, dos AssertionError, cero pendientes, 35 nombres
únicos. Una sonda adicional confirma role/aria-label null. Dirigida
EmptyState/Callout 65/65 y huella exacta. Los 35 originales se inspeccionaron
individualmente y archivaron/SHA: 34 a 1100×900, uno a 350×900 y uno RTL.
Sin calibración, freezes ni regenerar originales. Q323 y Q324 diferidos;
negativas y sonda adicional sólo en tmp. Sólo README conciliado; 16 fuentes
previas intactas, API/CSS/receta conservadas y cero extracción. Guard PASS/cero
errores; aviso D-1.2 de tabs congeladas triado. Gate integral exit 0: 605 archivos/7483 pruebas, docs 982/arts 25.
Censo 166 reviewed; check:gate 89 históricos/cero nuevos src/scripts, apps/base
check/build/smoke foundation 1/1 + boot 27/27.
Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
| ID | Resultado observado | Disposición |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Q323 | Actions sin label borra role=group y aria-label explícitos; control HTML válido conserva ambos, sonda adicional mide null/null. | Diferir precedencia compatible de atributos consumidor/shorthand. |
| Q324 | Media libre pinta img 800px sobre root 520px y desborda; control clamped queda dentro. README afirmaba image clamp no implementado. | Diferir adjudicación de contención parte/consumidor; conservar libertad del slot. |
[Ficha](./empty-state-native.md). AT/touch/oscuro/contraste/SSR/otros motores no acreditados.

@ -1,11 +1,11 @@
# EmptyState
Passive "nothing here yet / no results" display surface: media (icon plate
or free illustration) + title + description + actions, centered. Built
2026-07-21 as F1.2 of the blocks initiative (`docs/process/PLAN-blocks.md`);
reference floor fixed by the research dossier
(`docs/process/RESEARCH-blocks-references.md` §P3) under the E-2 rule
(parity floor = v1).
or free illustration) + title + description + actions, centered. Its origin and
reference floor are recorded in the
[blocks research dossier](../../../../../docs/process/RESEARCH-blocks-references.md)
§P3. Native behavior and deferred findings are recorded in the
[audit dossier](../../../../../docs/process/audit-uix-2026-09-19/empty-state-native.md).
## Baseline
@ -63,22 +63,21 @@ claves y las resuelve sobre el escenario.
## Comparativa
| Ref | Anatomy | What we adopted | What we didn't |
| ---------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| shadcn/ui `Empty` | Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription/EmptyContent | `Media variant="icon"` → our `kind`; part naming | The `EmptyHeader` grouping wrapper (root centers directly; one less part) |
| Chakra v3 `EmptyState` | Root/Content/Indicator/Title/Description | `size` sm/md/lg axis | The `Content` wrapper (same reason) |
| Atlaskit `EmptyState` | props-only (header/description/actions) | `headingLevel` model (our `level`, default h3 vs their h4) + `buttonGroupLabel` (our `Actions label`) + image clamp doctrine | `width narrow\|wide` (our measure token covers it); `isLoading` spinner-beside-actions (defer until a real consumer) |
| AntD `Empty` | image/description/footer | The compact in-collection concept (their SIMPLE preset ≈ our `sm`) | `ConfigProvider.renderEmpty` global injection (tracked gap below) |
| Polaris `EmptyState` | heading/image/action/secondaryAction/footerContent | Content doctrine: ONE primary action, verb-first; reason + next step | Required image (ours optional); `fullWidth/imageContained` |
| Ref | Anatomy | What we adopted | What we didn't |
| ---------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| shadcn/ui `Empty` | Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription/EmptyContent | `Media variant="icon"` → our `kind`; part naming | The `EmptyHeader` grouping wrapper (root centers directly; one less part) |
| Chakra v3 `EmptyState` | Root/Content/Indicator/Title/Description | `size` sm/md/lg axis | The `Content` wrapper (same reason) |
| Atlaskit `EmptyState` | props-only (header/description/actions) | `headingLevel` model (our `level`, default h3 vs their h4) + `buttonGroupLabel` (our `Actions label`) | `width narrow\|wide` (our token caps description only); `isLoading` spinner-beside-actions (defer until a real consumer) |
| AntD `Empty` | image/description/footer | The compact in-collection concept (their SIMPLE preset ≈ our `sm`) | `ConfigProvider.renderEmpty` global injection (tracked gap below) |
| Polaris `EmptyState` | heading/image/action/secondaryAction/footerContent | Content doctrine: ONE primary action, verb-first; reason + next step | Required image (ours optional); `fullWidth/imageContained` |
## Decisiones
- **Title is a real heading** (Atlaskit model), default `h3`, levels 2–6.
Rationale: empties often ARE the section content and should sit in the
document outline; the visual size is decoupled (rides `size`), so
choosing the right level costs nothing visually. (Callout will decide
the opposite — its title is NOT a heading — because callouts are inline
asides; the difference is deliberate.)
choosing the right level costs nothing visually. Callout chooses a
non-heading title for ancillary content; the difference is deliberate.
- **No `color`/intent prop**: a neutral display surface. Error-flavored
empties ("no results" after a failed search) are still neutral surfaces
with recovery actions — terminal flow states with evaluative status
@ -88,9 +87,21 @@ claves y las resuelve sobre el escenario.
tinted plate readable at every size without a parallel size table.
- **`sm` exists for collections**: Table/Select/Command empties compose
`<EmptyState size="sm">` inside their own empty slot.
- **Public tokens intentionally small**: gap · actions-gap · media-bg ·
media-fg · media-radius · description-measure (45ch). Density stays
internal on `data-size`.
- **Recipe contract**: spacing, typography and media tokens are declared in
`lib/recipes/base.ts`. `data-size` selects internal resolutions; consumers
may override the public recipe tokens, including density and title size.
- **Media semantics and containment belong to composition**: neither kind
stamps `aria-hidden`; meaningful images keep their consumer `alt`, decorative
content must opt out explicitly. The free slot does not clamp oversized art.
Consumers must currently size it to their container; Q324 defers deciding
whether the part should also provide containment.
- **Actions shorthand**: `label` creates the named group. Current generated role
and `aria-label` replace the same explicit HTML attributes even when `label`
is absent; Q323 tracks compatible precedence. Root ARIA passes through.
- **Copy and lifecycle**: children supply all visible text and parts. The Langs
label catalog is metadata here; wrappers do not translate or insert it. Root
appearance and removal remain consumer decisions, including after Button
click, Enter or Space.
## Passive justification
@ -117,3 +128,5 @@ justified empty state.
| `isLoading` action spinner (Atlaskit) | **diferir** until a real consumer asks; Buttons already have their own loading treatment |
| Illustration presets (AntD ships two images) | **descartar** — media is a free slot by design; apps bring their art (no baked visuals) |
| Global default media per app (theme-level) | **diferir** — candidate for a `recipes.empty-state.*` extension or app-level wrapper; no consumer yet |
| Explicit Actions role/aria-label are lost without shorthand label (Q323) | **diferir** — adjudicate precedence while preserving the plain default and named groups; see the native audit dossier. |
| Oversized free media can overflow its root (Q324) | **diferir** — decide part vs consumer containment without constraining all illustrations; consumers currently size their own media. |

@ -0,0 +1,323 @@
import { expect, it } from 'vitest';
import { page, userEvent } from 'vitest/browser';
import { flushSync } from 'svelte';
import { visit, type View } from '$uix/test/empty-state/empty-state-audit-view';
const node = (view: View, part: string) => view.node('#audit-empty-state-' + part);
const css = (view: View, part?: string) => getComputedStyle(part ? node(view, part) : view.root());
it('native default EmptyState is a passive centered surface with real heading and composed actions', () =>
visit(async (view) => {
expect(view.root().tagName).toBe('DIV');
expect(view.root().getAttribute('data-size')).toBe('md');
expect(view.root().hasAttribute('role')).toBe(false);
expect(view.root().hasAttribute('aria-live')).toBe(false);
expect(view.root().hasAttribute('tabindex')).toBe(false);
expect(node(view, 'title').tagName).toBe('H3');
expect(node(view, 'description').tagName).toBe('P');
expect(node(view, 'media').getAttribute('data-kind')).toBe('icon');
expect(css(view).display).toBe('flex');
expect(css(view).flexDirection).toBe('column');
expect(css(view).alignItems).toBe('center');
expect(css(view).textAlign).toBe('center');
await expect
.element(page.getByRole('heading', { name: 'No projects yet', level: 3 }))
.toBeVisible();
await view.capture('default');
}));
for (const size of ['sm', 'md', 'lg'] as const) {
it(
'native EmptyState ' + size + ' density paints padding typography and proportional icon plate',
() =>
visit(
async (view) => {
expect(view.root().getAttribute('data-size')).toBe(size);
expect(parseFloat(css(view).paddingBlockStart)).toBeGreaterThan(0);
expect(parseFloat(css(view, 'title').fontSize)).toBeGreaterThan(
parseFloat(css(view, 'description').fontSize)
);
expect(node(view, 'media').getBoundingClientRect().width).toBeCloseTo(
parseFloat(css(view, 'media').fontSize) * 2,
1
);
expect(css(view, 'media').backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
await view.capture('size-' + size);
},
{ size }
)
);
}
for (const level of [2, 3, 4, 5, 6] as const) {
it('native EmptyState heading level ' + level + ' remains decoupled from visual typography', () =>
visit(
async (view) => {
expect(node(view, 'title').tagName).toBe('H' + level);
expect(css(view, 'title').marginTop).toBe('0px');
await expect
.element(page.getByRole('heading', { name: 'No projects yet', level }))
.toBeVisible();
await view.capture('level-' + level);
},
{ level }
)
);
}
it('native EmptyState reactive heading level preserves ID copy and visual size', () =>
visit(async (view) => {
const font = css(view, 'title').fontSize;
flushSync(() => view.controls().level(5));
expect(node(view, 'title').tagName).toBe('H5');
expect(node(view, 'title').id).toBe('audit-empty-state-title');
expect(css(view, 'title').fontSize).toBe(font);
expect(node(view, 'title').textContent).toBe('No projects yet');
await view.capture('reactive-level');
}));
it('native EmptyState media kind leaves consumer art without icon plate chrome', () =>
visit(
async (view) => {
expect(node(view, 'media').getAttribute('data-kind')).toBe('media');
expect(css(view, 'media').backgroundColor).toBe('rgba(0, 0, 0, 0)');
expect(css(view, 'media').borderRadius).toBe('0px');
expect(node(view, 'image').getAttribute('alt')).toBe('Project illustration');
await expect.element(page.getByRole('img', { name: 'Project illustration' })).toBeVisible();
await view.capture('media-art');
},
{ kind: 'media', image: true }
));
it('native EmptyState reactive media kind removes and restores its plate on the same node', () =>
visit(async (view) => {
const media = node(view, 'media');
const width = media.getBoundingClientRect().width;
flushSync(() => view.controls().kind('media'));
expect(node(view, 'media')).toBe(media);
expect(css(view, 'media').backgroundColor).toBe('rgba(0, 0, 0, 0)');
flushSync(() => view.controls().kind('icon'));
expect(media.getBoundingClientRect().width).toBe(width);
await view.capture('media-roundtrip');
}));
it('native EmptyState Actions label creates a named group with two consumer buttons', () =>
visit(
async (view) => {
expect(node(view, 'actions').getAttribute('role')).toBe('group');
expect(node(view, 'actions').getAttribute('aria-label')).toBe('Project actions');
await expect
.element(page.getByRole('group', { name: 'Project actions', exact: true }))
.toBeVisible();
expect(node(view, 'actions').querySelectorAll('button').length).toBe(2);
await view.capture('named-actions');
},
{ label: 'Project actions' }
));
it('native unnamed EmptyState Actions stays a plain wrapping row', () =>
visit(async (view) => {
expect(node(view, 'actions').hasAttribute('role')).toBe(false);
expect(node(view, 'actions').hasAttribute('aria-label')).toBe(false);
expect(css(view, 'actions').flexWrap).toBe('wrap');
await view.capture('unnamed-actions');
}));
it('native EmptyState action group label changes and clears reactively', () =>
visit(async (view) => {
const actions = node(view, 'actions');
flushSync(() => view.controls().label('Recovery actions'));
expect(node(view, 'actions')).toBe(actions);
expect(actions.getAttribute('aria-label')).toBe('Recovery actions');
flushSync(() => view.controls().label(undefined));
expect(actions.hasAttribute('role')).toBe(false);
expect(actions.hasAttribute('aria-label')).toBe(false);
await view.capture('label-clear');
}));
it('native EmptyState consumer root role name and live policy pass through', () =>
visit(
async (view) => {
expect(view.root().getAttribute('role')).toBe('status');
expect(view.root().getAttribute('aria-label')).toBe('Project availability');
expect(view.root().getAttribute('aria-live')).toBe('polite');
await view.capture('explicit-root-aria');
},
{ rootRole: 'status', rootLabel: 'Project availability', rootLive: 'polite' }
));
it('native EmptyState all composed parts preserve consumer IDs classes and data attributes', () =>
visit(async (view) => {
expect(view.root().className).toBe('audit-root');
expect(view.root().getAttribute('data-probe')).toBe('root');
for (const part of ['media', 'title', 'description', 'actions']) {
expect(node(view, part).className).toBe('audit-' + part);
expect(node(view, part).getAttribute('data-probe')).toBe(part);
}
await view.capture('passthrough');
}));
it('native EmptyState omitted parts do not create fallback wrappers or content', () =>
visit(async (view) => {
flushSync(() => {
view.controls().media(false);
view.controls().description(false);
view.controls().actions(false);
});
expect(view.root().children.length).toBe(1);
expect(view.root().firstElementChild).toBe(node(view, 'title'));
await view.capture('title-only');
}));
it('native EmptyState empty composition remains a passive padded root', () =>
visit(
async (view) => {
expect(view.root().children.length).toBe(0);
expect(view.root().getBoundingClientRect().height).toBeGreaterThan(0);
await view.capture('empty');
},
{ media: false, title: false, description: false, actions: false }
));
it('native EmptyState consumer copy updates on the existing heading', () =>
visit(async (view) => {
const title = node(view, 'title');
flushSync(() => view.controls().copy('No matching projects'));
expect(node(view, 'title')).toBe(title);
expect(title.textContent).toBe('No matching projects');
await view.capture('copy-update');
}));
it('native EmptyState density updates preserve root and grow its padding and heading', () =>
visit(
async (view) => {
const root = view.root();
const small = {
padding: parseFloat(css(view).paddingTop),
font: parseFloat(css(view, 'title').fontSize)
};
flushSync(() => view.controls().size('lg'));
expect(view.root()).toBe(root);
expect(parseFloat(css(view).paddingTop)).toBeGreaterThan(small.padding);
expect(parseFloat(css(view, 'title').fontSize)).toBeGreaterThan(small.font);
await view.capture('size-update');
},
{ size: 'sm' }
));
it('native EmptyState responsive size follows live viewport and restores the same root', () =>
visit(
async (view) => {
const root = view.root();
await expect.poll(() => root.getAttribute('data-size')).toBe('lg');
await page.viewport(900, 900);
await view.ready();
await expect.poll(() => root.getAttribute('data-size')).toBe('md');
await page.viewport(350, 900);
await view.ready();
await expect.poll(() => root.getAttribute('data-size')).toBe('sm');
await page.viewport(1100, 900);
await view.ready();
await expect.poll(() => root.getAttribute('data-size')).toBe('lg');
expect(view.root()).toBe(root);
await view.capture('responsive');
},
{ size: { base: 'sm', md: 'md', lg: 'lg' } }
));
it('native EmptyState wraps description and actions inside a 350px viewport', () =>
visit(
async (view) => {
expect(view.root().getBoundingClientRect().right).toBeLessThanOrEqual(350);
expect(node(view, 'description').scrollWidth).toBeLessThanOrEqual(
node(view, 'description').clientWidth
);
expect(node(view, 'actions').getBoundingClientRect().height).toBeGreaterThan(
node(view, 'create').getBoundingClientRect().height
);
expect(node(view, 'import').getBoundingClientRect().right).toBeLessThanOrEqual(350);
await view.capture('narrow');
},
{ viewportWidth: 350 }
));
it('native EmptyState RTL preserves centered geometry and reverses the action row', () =>
visit(
async (view) => {
expect(css(view).direction).toBe('rtl');
expect(node(view, 'create').getBoundingClientRect().left).toBeGreaterThan(
node(view, 'import').getBoundingClientRect().left
);
expect(css(view).paddingLeft).toBe(css(view).paddingRight);
await view.capture('rtl');
},
{ dir: 'rtl' }
));
it('native EmptyState public tokens retune spacing typography media and description measure', () =>
visit(
async (view) => {
expect(css(view).gap).toBe('20px');
expect(css(view).paddingTop).toBe('22px');
expect(css(view, 'title').fontSize).toBe('26px');
expect(css(view, 'media').borderRadius).toBe('7px');
expect(node(view, 'description').getBoundingClientRect().width).toBeCloseTo(240, 0);
await view.capture('tokens');
},
{
style:
'--empty-state-gap:20px;--empty-state-padding-block-md:22px;--empty-state-title-font-size-md:26px;--empty-state-media-radius:7px;--empty-state-description-measure:240px;'
}
));
it('native EmptyState adds only its consumer buttons to sequential Tab order', () =>
visit(async (view) => {
await userEvent.click(page.getByRole('button', { name: 'Before', exact: true }));
await userEvent.tab();
expect(document.activeElement).toBe(node(view, 'create'));
await userEvent.tab();
expect(document.activeElement).toBe(node(view, 'import'));
await userEvent.tab();
expect(document.activeElement).toBe(node(view, 'after'));
await view.capture('tab');
}));
it('native EmptyState without actions adds no Tab stop', () =>
visit(
async (view) => {
await userEvent.click(page.getByRole('button', { name: 'Before', exact: true }));
await userEvent.tab();
expect(document.activeElement).toBe(node(view, 'after'));
await view.capture('no-actions-tab');
},
{ actions: false }
));
for (const activation of ['click', 'Enter', 'Space'] as const) {
it(
'native EmptyState consumer Button ' +
activation +
' activates once without committing the surface',
() =>
visit(async (view) => {
const button = node(view, 'create');
if (activation === 'click')
await userEvent.click(page.getByRole('button', { name: 'Create project', exact: true }));
else {
button.focus();
await userEvent.keyboard(activation === 'Enter' ? '{Enter}' : ' ');
}
expect(node(view, 'count').textContent).toBe('1');
expect(button.getAttribute('data-event')).toBe('contact-activate');
expect(view.root().hasAttribute('data-event')).toBe(false);
expect(view.root().isConnected).toBe(true);
await view.capture('activation-' + activation.toLowerCase());
})
);
}
it('native EmptyState removal belongs to the consumer action handler', () =>
visit(
async (view) => {
await userEvent.click(page.getByRole('button', { name: 'Create project', exact: true }));
expect(view.root()).toBeNull();
expect(node(view, 'count').textContent).toBe('1');
await view.capture('consumer-removal');
},
{ removeOnAction: true }
));
it('native nested EmptyState keeps independent density and heading semantics', () =>
visit(
async (view) => {
expect(node(view, 'nested').getAttribute('data-size')).toBe('sm');
expect(node(view, 'nested').querySelector('[data-empty-state-title]')!.tagName).toBe('H4');
expect(node(view, 'title').tagName).toBe('H3');
await view.capture('nested');
},
{ nested: true }
));
it('native EmptyState reduced motion stays static', () =>
visit(
async (view) => {
expect(css(view).animationName).toBe('none');
expect(css(view).transitionDuration).toBe('0s');
await view.capture('reduce');
},
{ reduce: true }
));

@ -0,0 +1,170 @@
<script module lang="ts">
import type {
EmptyStateProps,
EmptyStateMediaProps,
EmptyStateTitleProps
} from '$uix/eidos/components/empty-state';
export type Config = {
size?: EmptyStateProps['size'];
kind?: EmptyStateMediaProps['kind'];
level?: EmptyStateTitleProps['level'];
label?: string;
style?: string;
media?: boolean;
title?: boolean;
description?: boolean;
actions?: boolean;
image?: boolean;
nested?: boolean;
rootRole?: EmptyStateProps['role'];
rootLabel?: string;
rootLive?: EmptyStateProps['aria-live'];
removeOnAction?: boolean;
dir?: 'ltr' | 'rtl';
reduce?: boolean;
viewportWidth?: number;
};
export type Controls = {
size: (next: EmptyStateProps['size']) => void;
kind: (next: EmptyStateMediaProps['kind']) => void;
level: (next: EmptyStateTitleProps['level']) => void;
label: (next: string | undefined) => void;
media: (next: boolean) => void;
title: (next: boolean) => void;
description: (next: boolean) => void;
actions: (next: boolean) => void;
copy: (next: string) => void;
};
</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 EmptyState from '$uix/eidos/components/empty-state';
import Button from '$uix/eidos/components/button';
let {
uix,
initial = {},
onControls
}: { uix: ActiveUix; initial?: Config; onControls: (controls: Controls) => 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 kind = $state(initial.kind);
// svelte-ignore state_referenced_locally
let level = $state(initial.level);
// svelte-ignore state_referenced_locally
let label = $state(initial.label);
// svelte-ignore state_referenced_locally
let media = $state(initial.media ?? true);
// svelte-ignore state_referenced_locally
let title = $state(initial.title ?? true);
// svelte-ignore state_referenced_locally
let description = $state(initial.description ?? true);
// svelte-ignore state_referenced_locally
let actions = $state(initial.actions ?? true);
let copy = $state('No projects yet');
let count = $state(0);
let visible = $state(true);
const image =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="800" height="120" viewBox="0 0 800 120"><rect width="800" height="120" fill="#dae9f2"/><path d="M360 70h80V40h-80z" fill="#247095"/></svg>'
);
onMount(() =>
onControls({
size: (next) => (size = next),
kind: (next) => (kind = next),
level: (next) => (level = next),
label: (next) => (label = next),
media: (next) => (media = next),
title: (next) => (title = next),
description: (next) => (description = next),
actions: (next) => (actions = next),
copy: (next) => (copy = next)
})
);
function activate() {
count += 1;
if (initial.removeOnAction) visible = false;
}
</script>
<div
id="audit-empty-state-stage"
dir={initial.dir ?? 'ltr'}
data-motion={initial.reduce ? 'reduce' : undefined}
style="padding:24px;font:16px Arial,sans-serif;color:rgb(10,20,30)"
>
<div style="max-inline-size:520px">
<button id="audit-empty-state-before" type="button">Before</button>
{#if visible}
<EmptyState
id="audit-empty-state"
class="audit-root"
data-probe="root"
{size}
style={initial.style}
role={initial.rootRole}
aria-label={initial.rootLabel}
aria-live={initial.rootLive}
>
{#if media}
<EmptyState.Media
id="audit-empty-state-media"
class="audit-media"
data-probe="media"
{kind}
>
{#if initial.image}<img
id="audit-empty-state-image"
src={image}
alt="Project illustration"
width="128"
height="48"
/>
{:else}<span aria-hidden="true" data-audit-glyph>◇</span>{/if}
</EmptyState.Media>
{/if}
{#if title}<EmptyState.Title
id="audit-empty-state-title"
class="audit-title"
data-probe="title"
{level}>{copy}</EmptyState.Title
>{/if}
{#if description}<EmptyState.Description
id="audit-empty-state-description"
class="audit-description"
data-probe="description"
>Create your first project to get started. You can also import a project from an
existing workspace.</EmptyState.Description
>{/if}
{#if actions}<EmptyState.Actions
id="audit-empty-state-actions"
class="audit-actions"
data-probe="actions"
{label}
>
<Button id="audit-empty-state-create" onclick={activate}>Create project</Button>
<Button id="audit-empty-state-import" variant="outline" onclick={activate}
>Import project</Button
>
</EmptyState.Actions>{/if}
{#if initial.nested}<EmptyState id="audit-empty-state-nested" size="sm"
><EmptyState.Title level={4}>Nested empty</EmptyState.Title><EmptyState.Description
>Independent compact content</EmptyState.Description
></EmptyState
>{/if}
</EmptyState>
{/if}
<button id="audit-empty-state-after" type="button">After</button>
<output id="audit-empty-state-count">{count}</output>
</div>
</div>

@ -0,0 +1,54 @@
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 './empty-state-audit-harness.svelte';
export function renderEmptyState(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 component = mount(Harness, {
target,
props: { uix, initial, onControls: (value) => (controls = value) }
});
flushSync();
const ready = async () =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
return {
uix,
target,
controls: () => controls,
root: () => target.querySelector<HTMLElement>('#audit-empty-state')!,
node: (selector: string) => target.querySelector<HTMLElement>(selector)!,
ready,
async capture(name: string) {
if (import.meta.env.VITE_UIX_AUDIT_SCREENSHOTS === '1') {
await ready();
await page.screenshot({ path: 'tmp/empty-state-' + name + '.png' });
}
},
async dispose() {
await unmount(component);
uix.dispose();
target.remove();
}
};
}
export type View = ReturnType<typeof renderEmptyState>;
export async function visit(fn: (view: View) => Promise<void>, initial: Config = {}) {
await page.viewport(initial.viewportWidth ?? 1100, 900);
const view = renderEmptyState(initial);
try {
await view.ready();
await fn(view);
} finally {
await view.dispose();
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.