test(uix): audit Callout native behavior

astra
dev 6 days ago
parent 9e6b85d89a
commit e60e079a26

@ -9,9 +9,9 @@ status: reviewed
# Retomar la fase 6 UIX el 2 de octubre
Workspace G:\dev\svelte\vicen, rama astra, destino autorizado gita/astra.
El usuario pidió terminar los trabajos en curso, comitear, pushear y parar
hasta mañana. Meter cerró; Banner es el último trabajo de este lote.
La sesión del 1 se cierra tras Banner. Al retomar comprobar HEAD,
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.
Al retomar comprobar HEAD,
gita/astra y estado local; no depender de un hash autorreferente en este fichero.
El [handoff del 1](./HANDOFF-uix-quality-2026-10-01.md) conserva el recorrido
@ -22,10 +22,11 @@ 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 |
| 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 |
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
@ -50,19 +51,24 @@ nodo real. Banner raíz declara cero eventos; su Close compone Button, que
posee el teclado y contact-activate pre. El consumidor posee visibilidad;
click/Enter/Space disparan una ocurrencia y sólo su handler desmonta la franja.
## Siguiente: Callout
## Callout retomado el 2026-10-01
Retomar [Banner/Callout](./audit-uix-2026-09-19/banner-callout.md).
Callout tiene sólo lectura preparatoria de algunos ficheros, sin baseline,
capturas, cambios ni promoción. Releer completas las cuatro guías obligatorias
antes de tocarlo; terminar lectura de CSS/receta/Langs/Air y referencias.
El usuario pidió continuar. La baseline nueva dio 32 positivos y siete
AssertionError, cero pendientes y 39 nombres únicos; todos los originales
inspeccionados/archivados/SHA, uno a 350px y uno RTL. Dirigida Callout/Banner
67/67 y huella exacta. Q319–Q322 y Q313 diferidos. Sólo README conciliado,
wrappers/context/Morfo/tipos/Langs/CSS/receta/API intactos, cero extracción.
[Ficha](./audit-uix-2026-09-19/callout-native.md). Guard PASS y aviso D-1.2
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.
Candidatos vistos por lectura, todavía sin hallazgo adjudicado:
registro de Title y aria-labelledby externo, varios Title con un mismo ID,
passthrough de ID y composición de style custom. Medirlos con instancias
reales, controles y capturas antes de inferir un defecto o asignar ID.
Conservar role=note pasivo y escalación explícita del consumidor; no fusionar
provider, context ni CSS con Banner por parecido visual.
## Siguiente: EmptyState
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
obligatorias, terminar fuentes/receta/Langs/Air y referencias antes de escribir
la baseline. Mantener roles y acciones compuestas en su propietario actual.
## Disciplina de continuidad
@ -72,14 +78,14 @@ provider, context ni CSS con Banner por parecido visual.
- .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}-audit-evidence-2026-10-01/.
tmp/{progress,meter,banner,callout}-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 el cierre de Banner. Los scripts
close-progress.mjs, close-meter.mjs ni los cierres de Banner/Callout. 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 del push de este cierre quedarán en la carpeta de evidencia
de Banner. Comprobar el código final; avisos de parser de fixtures negativos
- 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
no son por sí solos fallo de gate.

@ -4215,3 +4215,20 @@ El usuario pidió terminar el trabajo en curso, commit/push y parar.
Callout queda para mañana, con lectura preparatoria y sin baseline ni cambios.
[Handoff del 2](./HANDOFF-uix-quality-2026-10-02.md).
Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
### Callout — cierre neutral (2026-10-01)
Baseline única: 32 positivos, siete AssertionError, cero pendientes y 39 nombres
únicos. Huella promovida exacta; dirigida Callout/Banner 67/67. Los 39 originales
se inspeccionaron individualmente y archivaron/SHA: uno a 350px y uno RTL.
Q319–Q322 y extensión Q313 diferidos; negativas sólo en tmp. Registro de Title
limpia al desmontar y peers/contextos anidados conservan independencia.
Sólo README de producción conciliado; wrappers/context/Morfo/tipos/Langs/CSS/
receta/API intactos y cero lógica extraída. Tests y dos helpers fuera de wrappers.
Guard PASS/cero errores y aviso D-1.2 de tabs congeladas triado. 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.
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.

@ -77,3 +77,20 @@ El usuario pidió terminar el trabajo en curso, commit/push y parar.
Callout queda para mañana, con lectura preparatoria y sin baseline ni cambios.
[Handoff del 2](../HANDOFF-uix-quality-2026-10-02.md).
Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
### Callout — cierre neutral (2026-10-01)
Baseline única: 32 positivos, siete AssertionError, cero pendientes y 39 nombres
únicos. Huella promovida exacta; dirigida Callout/Banner 67/67. Los 39 originales
se inspeccionaron individualmente y archivaron/SHA: uno a 350px y uno RTL.
Q319–Q322 y extensión Q313 diferidos; negativas sólo en tmp. Registro de Title
limpia al desmontar y peers/contextos anidados conservan independencia.
Sólo README de producción conciliado; wrappers/context/Morfo/tipos/Langs/CSS/
receta/API intactos y cero lógica extraída. Tests y dos helpers fuera de wrappers.
Guard PASS/cero errores y aviso D-1.2 de tabs congeladas triado. 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.
Censo 165 reviewed. Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
[Ficha](./callout-native.md). EmptyState es el siguiente
candidato; no tiene baseline nueva ni cambios de esta sesión.

@ -0,0 +1,90 @@
---
title: UIX — Callout, nota pasiva y registro de Title
type: notes
audience: human + agent
authority: process — auditoría neutral, no contrato nuevo
status: reviewed
---
# Callout — continuación nativa
Fecha: 2026-10-01. Alcance neutral autorizado por la instrucción de continuar
el handoff. Las cuatro guías obligatorias se leyeron completas antes de escribir
la baseline. Se revisaron Morfo, root/Icon/Title/Content/context/types/index/CSS/
README, catálogo Langs, receta y los helpers comunes de color/style e IntentIcon.
La [ficha de familia](./banner-callout.md) conserva la pasada documental previa.
| Aspecto | [Radix Themes Callout](https://www.radix-ui.com/themes/docs/components/callout) | [Chakra Alert](https://chakra-ui.com/docs/components/alert) | [Mantine Alert](https://mantine.dev/core/alert/) | [MUI Alert](https://mui.com/material-ui/react-alert/) | Decisión UIX |
| ------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------- | ---------------------------------------------------- |
| Anatomía | Root/Icon/Text | Root/Indicator/Content/Title/Description | Título/icono/cuerpo/cierre | Icono/mensaje/acción | Conservar root/Icon/Title/Content |
| Presentación | Tres variantes; talla responsive 1–3 | Cuatro variantes; sm/md/lg | Color, variante y radio | standard/filled/outlined | Conservar la nota actual; variantes/tallas diferidas |
| Accesibilidad | Root div, escalación explícita | Mensajes de sistema | alert con relaciones de título/cuerpo | alert predeterminado; status explícito | Conservar note pasivo y escalación del consumidor |
| Cierre | Sin parte dedicada | Cierre por composición | withCloseButton | onClose/acción | Diferir contrato activo; cero dismiss inline |
`git ls-tree -r --name-only morfo-runtime -- src/uix/air` no contiene Callout.
No se inventa una baseline Air. El rol
[note de WAI-ARIA](https://www.w3.org/TR/wai-aria-1.2/#note) describe contenido
auxiliar; no requiere un patrón de widget. Morfo es Eidos-native, sin eventos,
teclado, estado interactivo, Soma ni pack Sema. El consumidor posee la inserción
dinámica y el rol. Root/Title sí requieren el runtime visual y Langs reales.
No se fusionan provider/context/CSS con Banner: nombre, paleta y geometría son
políticas distintas, sin bloque idéntico que justifique una extracción.
Baseline única en Chromium: **32 positivos, siete AssertionError, cero pendientes
y 39 nombres únicos**. No hubo calibración ni cambios de aserciones. Los siete
negativos siguen sólo en `tmp/callout-audit-evidence-2026-10-01/`, junto con
fuentes originales, log, JSON, preflight y scripts de promoción/verificación.
Los 32 positivos se promueven con dos helpers consumidores fuera de los wrappers;
se retira sólo instrumentación exclusiva de nombres/IDs negativos.
Los positivos miden note nombrado, icono decorativo, título div, cuerpo, cuatro
defaults de intent y paleta, cinco overrides canónicos/donantes, precedencia
evaluativa, color raw, estilo terminado, custom parts, idioma es/en, desmontaje/
remontaje de Title, omisión de partes, composición vacía, roles explícitos,
aria-label sin Title, mutaciones en el mismo nodo, independencia de peers y
contextos anidados, RTL, 350px, tokens, Tab y reduce estático. El canario de color
tertiary tiene pintura resuelta: no amplía Q318 de Banner. No se expone ref en
los tipos públicos de Callout, por lo que no se adjudica L-152.
Los 39 PNG originales se inspeccionaron individualmente y archivaron/SHA:
38 a 1100×900 y uno a 350×900; uno RTL. No se congeló el movimiento, regeneró
ni sobrescribió ninguna captura original. La nota neutral tiene borde gris y
campana; affirm, risk y threat cambian glifo, texto y acento. Las superficies
canónicas son muy tenues en claro; el color raw crea un fondo azul más visible.
La composición sin Icon elimina la sangría y la de 350px envuelve sin desbordar.
RTL lleva el acento e Icon a la derecha. El título español dice «Precaución».
Los tokens amplían padding/tipo/acento/radio y Tab pasa al botón siguiente.
La captura sin semicolon conserva el marker custom pero pierde el seed y el
padding válido: aparece la pintura violeta host y desaparece el respiro interior,
frente al control positivo con terminador. En el root sin Icon que contiene un
Callout con Icon, la rejilla conserva dos columnas y una sangría extra de gap;
el control sin anidamiento tiene una columna. Dos Titles se muestran en filas
distintas pero comparten ID. Los fallos de nombre externo/ID se acreditan por
DOM y aserciones; las capturas por sí solas no acreditan el resultado accesible.
| ID | Reproducción | Disposición |
| ---- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Q319 | aria-labelledby externo termina null sin Title bajo note y status; el control HTML conserva el mismo ID externo. | Diferir precedencia del nombre explícito frente a la asociación automática; cubrir roles y ciclo de Title. |
| Q320 | Title id=consumer-title termina en el generado; cambiar a changed-title tampoco modifica ese ID. El control HTML sí cambia. | Diferir compatibilidad de id público y registro reactivo; no confundir con Q241, donde el DOM sí cambia. |
| Q321 | Dos Titles de una instancia emiten el mismo ID; dos roots separados sí emiten IDs distintos. | Diferir política de múltiples títulos y unicidad/registro; conservar montaje/desmontaje. |
| Q322 | :has([data-callout-icon]) alcanza el Icon de la nota anidada; root sin Icon conserva dos columnas en vez de una. | Diferir alcance de selectores a las partes propias; cubrir combinaciones de Icon exterior/interior. |
| Q313 | style sin ; final absorbe --color-custom; el control con ; preserva seed y padding. | Ampliar el hallazgo del helper común, sin repararlo en esta pasada. |
Sólo se concilia el README de producción, incluidas comparativas y disposiciones.
Wrappers, Morfo, context, tipos, Langs, CSS, receta y API ejecutable intactos;
cero lógica extraída. Los originales y sus dimensiones se verifican con SHA.
AT, touch, tema oscuro, contraste, SSR y otros motores no quedan certificados.
Guard final PASS/cero errores; un aviso D-1.2 de tabs congeladas, diferido a
la reconstrucción de demo. Dirigida Callout/Banner 67/67, huella exacta y
15 fuentes previas intactas. Censo 165 reviewed; fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
## Verificación de cierre
Gate integral exit 0: 604 archivos/7450 pruebas, docs 981/arts 25 sin errores
ni avisos. check:gate acepta los 89 errores históricos del ledger, cero nuevos
src/scripts. apps/base check/build/smoke foundation 1/1 + boot 27/27, sin
pageerror, console.error ni CSP. Dirigida 67/67, 32 positivos con huella exacta,
39 originales/SHA y 15 fuentes previas intactas. Guard PASS y aviso D-1.2
triado. Censo 165 reviewed; siguiente candidato EmptyState, sin baseline nueva.

@ -46,6 +46,12 @@ variantes, con controles de paleta reales. L-152 se amplía con Close frente
a Button; 35 positivos/cinco AssertionError, 40 originales inspeccionados/SHA
y huella promovida exacta. Gate integral exit 0: 603 archivos/7418 pruebas; censo 164 reviewed.
Callout reproduce siete AssertionError: Q319 (nombre externo), Q320 (ID de
Title ignorado), Q321 (IDs duplicados) y Q322 (Icon anidado mantiene columna
exterior). Amplía Q313 con style sin terminador. Los 32 positivos promovidos
conservan huella exacta; 39 originales inspeccionados/SHA. Gate integral exit 0:
604 archivos/7450 pruebas; censo 165 reviewed.
| Ampliación de cola | ID | Condición de cierre |
| -------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Demora y sustitución de Image | Q311, Q312 | Adjudicar precedencia part/root y conservar la URL alternativa durante loading; cubrir reintentos, alternativa fallida, cambio externo y SSR. |
@ -55,6 +61,10 @@ y huella promovida exacta. Gate integral exit 0: 603 archivos/7418 pruebas; cens
| Nombre tras desmontar Label | Q316 | Resolver vida del registro y fallback conservando precedencia externa; separar de los registros que requieren labels persistentes. |
| Transición circular reducida | Q317 | Adjudicar eliminación de transiciones bajo reduce y comprobar barra, anillo y overrides antes de cambiar CSS. |
| Paleta tertiary de Banner | Q318 | Adjudicar completar receta o restringir tipo; cubrir las cuatro variantes, responsive y retintado antes de cambiar producción. |
| Nombre externo de Callout | Q319 | Adjudicar precedencia de aria-labelledby explícito, roles y ciclo de Title; mantener el nombre automático válido. |
| 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. |
Q34 se reprodujo con el proveedor Sticky y geometría real en Chromium.
Q35–Q36 se reprodujeron con el wrapper SkipLink montado en Chromium.
@ -3174,3 +3184,25 @@ El usuario pidió terminar el trabajo en curso, commit/push y parar.
Callout queda para mañana, con lectura preparatoria y sin baseline ni cambios.
[Handoff del 2](../HANDOFF-uix-quality-2026-10-02.md).
Fase 6 abierta, fases 0–5 sin cerrar y fase 7 pendiente.
### Callout: Q319–Q322 y extensión de Q313
Baseline única: 32 positivos, siete AssertionError, cero pendientes y 39 nombres
únicos. Los 39 PNG originales se inspeccionaron individualmente y archivaron/SHA,
uno a 350px y uno RTL. Dirigida Callout/Banner 67/67; huella positiva exacta.
| ID | Resultado observado | Disposición |
| ---- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Q319 | aria-labelledby externo termina null sin Title bajo note/status, frente a control HTML válido | Diferir precedencia externa/automática y probar roles/lifecycle |
| Q320 | ID explícito y actualizado de Title se ignora; siempre se pinta el generado, frente a control que sí cambia | Diferir id público y registro; no extender Q241 porque el DOM no cambia |
| Q321 | Dos Titles de un root comparten ID; peers tienen IDs independientes | Diferir política de múltiples Titles y unicidad |
| Q322 | Icon anidado satisface :has y el root sin Icon conserva dos columnas y gap extra; control sin nesting colapsa | Diferir alcance de selectores de partes propias |
| Q313 | style sin ; final pierde seed custom y padding válido; control terminado preserva ambos | Ampliar helper común; diferir corrección |
Las negativas permanecen sólo en tmp. Wrappers/context/Morfo/tipos/Langs/CSS/
receta/API intactos; sólo README conciliado y canarios/helper nuevos fuera del
wrapper. Cero extracción. No se adjudica L-152 porque no hay ref público; tertiary
sí pinta y no amplía Q318. AT/oscuro/contraste/SSR/touch/otros motores no acreditados.
[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.

@ -24,6 +24,13 @@ rule (parity floor = v1).
33 donor scales AND raw CSS colors resolve through the THM-2 shared layer
with zero extra CSS.
The [native audit](../../../../../docs/process/audit-uix-2026-09-19/callout-native.md)
measures the current contract with real services. Title registration cleans up on
unmount and peers have separate IDs. The root's automatic `aria-labelledby`
applies only to `note`; external `aria-labelledby` is overwritten even without a
Title (Q319). Title's generated ID overrides a consumer ID (Q320), and multiple
Titles in one root share that ID (Q321). These gaps remain deferred.
## Talla y tema
**34 clave(s) pública(s)** en `lib/recipes/base.ts` (bloque `callout`).
@ -45,17 +52,34 @@ claves y las resuelve sobre el escenario.
## Comparativa
| Ref | Equivalente | Qué adoptamos | Qué no |
| ---------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| GitHub alerts | NOTE/TIP/IMPORTANT/WARNING/CAUTION | el vocabulario de 5 y el **label de texto visible** (títulos default localizados — la semántica nunca viaja solo en color/icono) | un 5º enum: IMPORTANT = `intent="neutral" color="…"` + título propio (ver Decisiones) |
| Radix Themes `Callout` | Root/Icon/Text | la anatomía; el split intent-vs-color; la escalación de role como decisión del consumidor (nosotros la tipamos como prop) | `variant surface/outline` y `size` (diferidos con disposición) |
| Docusaurus admonitions | note/tip/info/warning/danger | nesting tolerado; su split note/info = elección de pintura (`color`), no enum nuevo | tipos custom vía config (nuestro equivalente: `color` + título propio) |
| Starlight asides | note/tip/caution/danger | — (subset del vocabulario GitHub) | icono custom por sintaxis (nuestro: children de `.Icon`) |
| shadcn/ui `Alert` | default/destructive | — | **`role="alert"` hardcodeado en contenido estático — bug de referencia que NO copiamos** (MDN: alert solo anuncia en update, y asertivo) |
| HeroUI `Alert` | 6 colores + closable | — | closable como prop (dismiss = evento real → pasada soma+sema, regla de admisión) |
| Ref | Equivalente | Qué adoptamos | Qué no |
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| GitHub alerts | NOTE/TIP/IMPORTANT/WARNING/CAUTION | el vocabulario de 5 y el **label de texto visible** (títulos default localizados — la semántica nunca viaja solo en color/icono) | un 5º enum: IMPORTANT = `intent="neutral" color="…"` + título propio (ver Decisiones) |
| Radix Themes `Callout` | Root/Icon/Text | la anatomía; el split intent-vs-color; la escalación de role como decisión del consumidor (nosotros la tipamos como prop) | `variant surface/outline` y `size` (diferidos con disposición) |
| Docusaurus admonitions | note/tip/info/warning/danger | nesting tolerado; su split note/info = elección de pintura (`color`), no enum nuevo | tipos custom vía config (nuestro equivalente: `color` + título propio) |
| [Starlight asides](https://starlight.astro.build/guides/authoring-content/#asides) | note/tip/caution/danger | visible titles and custom title/icon support | syntax-driven configuration; UIX composes children of Title/Icon |
| shadcn/ui `Alert` | default/destructive | — | default alert role: UIX chooses a passive note and explicit consumer escalation |
| HeroUI `Alert` | 6 colores + closable | — | closable como prop (dismiss = evento real → pasada soma+sema, regla de admisión) |
| [Chakra Alert](https://chakra-ui.com/docs/components/alert) | Root/Indicator/Content/Title/Description; four variants, sm/md/lg | composed anatomy | status, variants and dismissal stay outside this neutral audit |
| [Mantine Alert](https://mantine.dev/core/alert/) | title/icon/body/optional close; alert with title/body relations | optional title composition | default alert role and close prop; UIX keeps passive note |
| [MUI Alert](https://mui.com/material-ui/react-alert/) | icon/message/action; standard/filled/outlined; alert or explicit status | explicit consumer escalation | new variants, severity enum and dismissal |
## Decisiones
- **Naming precedence is measured, not configurable**: a composed Title owns the
generated name under `note`. A root without Title can use `aria-label`, but
explicit `aria-labelledby` is currently overwritten. Escalating the role clears
the automatic Title association; it does not add an explicit `aria-live`.
Q319 defers the decision on external-name precedence.
- **Nesting has a layout boundary**: a root without its own Icon collapses to one
column only when no descendant Icon exists. A nested Icon prevents collapse and
leaves a visible extra gap (Q322, deferred). Contexts and title names remain
independent; this is a selector-scope gap.
- **Custom style fragments need a terminator**: a raw color with a style string
lacking the final semicolon loses its `--color-custom` seed (Q313). A terminated
string preserves both the override and the custom palette. The shared helper
remains unchanged pending the separate correction pass.
- **El hueco IMPORTANT se resuelve con el modelo Radix, no con un 5º
enum**: intent = semántica (4 valores que mapean el vocabulario),
`color` = pintura bajo neutral. `<Callout intent="neutral" color="plum">
@ -66,7 +90,7 @@ claves y las resuelve sobre el escenario.
(`aria-labelledby`) SOLO mientras un Title está montado (registro
reactivo vía context local). Escalación tipada: prop `role`
(`status`/`alert`/`none`) para callouts insertados dinámicamente.
- **El Title NO es heading**: ninguna referencia lo hace, y un heading
- **El Title NO es heading**: es una decisión de composición de UIX, y un heading
contaminaría el outline que escanea nuestro propio `anchor-nav`
(interacción intra-suite decidida a propósito — empty-state/result
decidieron lo contrario porque SON contenido de sección).
@ -102,3 +126,9 @@ justified empty state.
| `variant` surface/outline (Radix) y `size` | **diferir** — v1 = el soft admonition; añadir cuando un consumidor real (prose/docs-shell F4) lo pida |
| Tipos custom con registro (Docusaurus `admonitions.keywords`) | **descartar** — el equivalente composicional ya existe: `color` + título propio; un registro sería API paralela |
| Integración prose (estilos de callout dentro de `[data-prose]`) | **diferir** a F1.5 `prose` — su recipe debe tolerar el componente embebido vía la frontera de auto-exclusión |
| External name precedence (Q319) | **diferir** — preserve or adjudicate explicit aria-labelledby across roles and Title mount/unmount; aria-label without Title works |
| Public Title id (Q320) | **diferir** — generated ID currently overrides initial/reactive consumer IDs; decide compatibility before changing registration |
| Multiple Title IDs (Q321) | **diferir** — define multiple-title policy and preserve unique IDs and cleanup |
| Nested Icon selector scope (Q322) | **diferir** — scope collapse to each root’s own parts; test all outer/inner Icon combinations |
| Custom style terminator (Q313) | **diferir** — fix the shared composition helper in its own pass; terminated strings preserve the seed |
| Frozen demo tabs (D-1.2) | **diferir** — rebuild against the canonical template; demo edits remain outside this audit |

@ -0,0 +1,282 @@
import { expect, it } from 'vitest';
import { page, userEvent } from 'vitest/browser';
import { flushSync } from 'svelte';
import { visit, type View } from '$uix/test/callout/callout-audit-view';
const css = (view: View) => getComputedStyle(view.root());
const titles = { neutral: 'Note', affirm: 'Tip', risk: 'Warning', threat: 'Caution' } as const;
it('native default Callout is a passive named note with decorative icon and body', () =>
visit(async (view) => {
const root = view.root();
expect(root.tagName).toBe('DIV');
expect(root.getAttribute('role')).toBe('note');
expect(root.getAttribute('aria-labelledby')).toBe(view.title()!.id);
expect(view.title()!.textContent).toBe('Note');
expect(view.icon()!.getAttribute('aria-hidden')).toBe('true');
expect(view.icon()!.querySelector('svg')).not.toBeNull();
expect(root.hasAttribute('aria-live')).toBe(false);
expect(root.hasAttribute('tabindex')).toBe(false);
expect(root.querySelector('h1,h2,h3,h4,h5,h6')).toBeNull();
expect(root.getAttribute('data-intent')).toBe('neutral');
expect(root.getAttribute('data-color')).toBe('neutral');
expect(root.getAttribute('data-probe')).toBe('callout');
expect(css(view).display).toBe('grid');
await expect.element(page.getByRole('note', { name: 'Note', exact: true })).toBeVisible();
await view.capture('default');
}));
for (const intent of ['neutral', 'affirm', 'risk', 'threat'] as const) {
it('native Callout ' + intent + ' resolves title glyph and palette from its intent', () =>
visit(
async (view) => {
const control = getComputedStyle(view.node('#audit-callout-palette-control'));
expect(view.title()!.textContent).toBe(titles[intent]);
expect(view.icon()!.querySelector('svg')).not.toBeNull();
expect(css(view).backgroundColor).toBe(control.backgroundColor);
expect(css(view).borderInlineStartColor).toBe(control.borderInlineStartColor);
expect(getComputedStyle(view.title()!).color).toBe(control.color);
expect(control.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
await view.capture('intent-' + intent);
},
{ intent }
)
);
}
for (const color of ['primary', 'secondary', 'tertiary', 'teal', 'plum'] as const) {
it('native neutral Callout ' + color + ' paint override keeps its Note label', () =>
visit(
async (view) => {
expect(view.root().getAttribute('data-color')).toBe(color);
expect(view.title()!.textContent).toBe('Note');
expect(css(view).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
expect(css(view).backgroundColor).not.toBe('rgb(255, 255, 255)');
await view.capture('color-' + color);
},
{ color }
)
);
}
it('native evaluative Callout intent wins over a donor color override', () =>
visit(
async (view) => {
expect(view.root().getAttribute('data-color')).toBe('risk');
expect(view.title()!.textContent).toBe('Warning');
expect(css(view).backgroundColor).toBe(
getComputedStyle(view.node('#audit-callout-palette-control')).backgroundColor
);
await view.capture('intent-wins');
},
{ intent: 'risk', color: 'plum' }
));
it('native Callout raw CSS color seeds a nontransparent custom palette', () =>
visit(
async (view) => {
expect(view.root().hasAttribute('data-color')).toBe(false);
expect(view.root().hasAttribute('data-color-custom')).toBe(true);
expect(view.root().style.getPropertyValue('--color-custom')).toBe('rgb(20, 110, 150)');
expect(css(view).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
await view.capture('custom-color');
},
{ color: 'rgb(20, 110, 150)' }
));
it('native Callout custom color composes after a terminated inline style', () =>
visit(
async (view) => {
expect(css(view).paddingTop).toBe('28px');
expect(view.root().style.getPropertyValue('--color-custom')).toBe('rgb(20, 110, 150)');
expect(css(view).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
await view.capture('custom-style-terminated');
},
{ color: 'rgb(20, 110, 150)', style: '--callout-padding:28px;' }
));
it('native Callout custom title and icon replace both defaults', () =>
visit(
async (view) => {
expect(view.title()!.textContent).toBe('Important');
expect(view.icon()!.textContent).toBe('★');
expect(view.icon()!.querySelector('svg')).toBeNull();
expect(view.icon()!.getAttribute('aria-hidden')).toBe('true');
await view.capture('custom-parts');
},
{ customTitle: 'Important', customIcon: true, color: 'plum' }
));
it('native Callout localized titles react to Spanish and back to English', () =>
visit(
async (view) => {
flushSync(() => view.uix.langs.setLocale('es'));
expect(view.title()!.textContent).toBe('Precaución');
await view.capture('spanish');
flushSync(() => view.uix.langs.setLocale('en'));
expect(view.title()!.textContent).toBe('Caution');
},
{ intent: 'threat' }
));
it('native Callout title registry clears on unmount and restores on remount', () =>
visit(async (view) => {
const originalId = view.title()!.id;
flushSync(() => view.controls().title(false));
expect(view.title()).toBeNull();
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
flushSync(() => view.controls().title(true));
expect(view.title()!.id).toBe(originalId);
expect(view.root().getAttribute('aria-labelledby')).toBe(originalId);
await view.capture('title-remount');
}));
it('native Callout without Title remains an unnamed note', () =>
visit(
async (view) => {
expect(view.title()).toBeNull();
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
expect(view.root().getAttribute('role')).toBe('note');
await view.capture('no-title');
},
{ title: false }
));
it('native Callout without Icon collapses the empty grid column', () =>
visit(
async (view) => {
expect(view.icon()).toBeNull();
expect(css(view).gridTemplateColumns.split(' ').length).toBe(1);
expect(getComputedStyle(view.title()!).gridColumnStart).toBe('1');
await view.capture('no-icon');
},
{ icon: false }
));
it('native Callout empty composition retains a passive surface', () =>
visit(
async (view) => {
expect(view.root().children.length).toBe(0);
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
expect(css(view).gridTemplateColumns.split(' ').length).toBe(1);
await view.capture('empty');
},
{ icon: false, title: false, content: false }
));
for (const role of ['status', 'alert', 'none'] as const) {
it('native Callout explicit ' + role + ' role leaves escalation to the consumer', () =>
visit(
async (view) => {
expect(view.root().getAttribute('role')).toBe(role === 'none' ? null : role);
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
expect(view.root().hasAttribute('aria-live')).toBe(false);
await view.capture('role-' + role);
},
{ role }
)
);
}
it('native Callout explicit aria-label names a note without a Title', () =>
visit(
async (view) => {
await expect
.element(page.getByRole('note', { name: 'Consumer note', exact: true }))
.toBeVisible();
await view.capture('aria-label');
},
{ title: false, ariaLabel: 'Consumer note' }
));
it('native Callout presentation mutates on the same root with reactive defaults', () =>
visit(async (view) => {
const root = view.root();
const icon = view.icon();
const path = icon!.querySelector('svg')!.innerHTML;
flushSync(() => view.controls().intent('risk'));
expect(view.root()).toBe(root);
expect(view.icon()).toBe(icon);
expect(view.icon()!.querySelector('svg')!.innerHTML).not.toBe(path);
expect(view.title()!.textContent).toBe('Warning');
flushSync(() => {
view.controls().intent('neutral');
view.controls().color('teal');
view.controls().customTitle('Information');
});
expect(view.title()!.textContent).toBe('Information');
expect(root.getAttribute('data-color')).toBe('teal');
await view.capture('reactive');
}));
it('native Callout role changes preserve Title registration when returning to note', () =>
visit(async (view) => {
const titleId = view.title()!.id;
flushSync(() => view.controls().role('status'));
expect(view.root().getAttribute('aria-labelledby')).toBeNull();
flushSync(() => view.controls().role('note'));
expect(view.root().getAttribute('aria-labelledby')).toBe(titleId);
await view.capture('role-roundtrip');
}));
it('native Callout peers own independent title IDs and contexts', () =>
visit(
async (view) => {
const peer = view.node('#audit-callout-peer');
expect(peer.getAttribute('aria-labelledby')).not.toBe(
view.root().getAttribute('aria-labelledby')
);
expect(peer.querySelector('[data-callout-title]')!.textContent).toBe('Tip');
await view.capture('peers');
},
{ peer: true }
));
it('native nested Callout owns a separate intent name and spacing', () =>
visit(
async (view) => {
const nested = view.node('#audit-callout-nested');
expect(nested.querySelector('[data-callout-title]')!.textContent).toBe('Warning');
expect(nested.getAttribute('aria-labelledby')).not.toBe(
view.root().getAttribute('aria-labelledby')
);
expect(getComputedStyle(nested).marginBlockStart).toBe('8px');
expect(view.title()!.textContent).toBe('Note');
await view.capture('nested');
},
{ nested: true }
));
it('native Callout logical accent and grid mirror in RTL', () =>
visit(
async (view) => {
expect(css(view).borderRightWidth).toBe('3px');
expect(css(view).borderLeftWidth).toBe('0px');
expect(view.icon()!.getBoundingClientRect().left).toBeGreaterThan(
view.title()!.getBoundingClientRect().right
);
await view.capture('rtl');
},
{ dir: 'rtl' }
));
it('native Callout wraps within a 350px viewport', () =>
visit(
async (view) => {
expect(view.root().getBoundingClientRect().right).toBeLessThanOrEqual(350);
expect(view.node('#audit-callout-content').scrollWidth).toBeLessThanOrEqual(
view.node('#audit-callout-content').clientWidth
);
await view.capture('narrow');
},
{ viewportWidth: 350 }
));
it('native Callout token overrides apply to typography spacing and accent', () =>
visit(
async (view) => {
expect(css(view).paddingTop).toBe('24px');
expect(css(view).fontSize).toBe('20px');
expect(css(view).borderInlineStartWidth).toBe('5px');
expect(css(view).borderRadius).toBe('12px');
await view.capture('tokens');
},
{
style:
'--callout-padding:24px;--callout-font-size:20px;--callout-accent-width:5px;--callout-radius:12px;'
}
));
it('native passive Callout adds no Tab stop between adjacent controls', () =>
visit(async (view) => {
await userEvent.click(page.getByRole('button', { name: 'Before', exact: true }));
await userEvent.tab();
expect(document.activeElement).toBe(view.node('#audit-callout-after'));
await view.capture('tab');
}));
it('native Callout reduced motion remains a static note', () =>
visit(
async (view) => {
expect(css(view).animationName).toBe('none');
expect(css(view).transitionDuration).toBe('0s');
await view.capture('reduce');
},
{ reduce: true }
));

@ -0,0 +1,137 @@
<script module lang="ts">
import type { CalloutProps } from '$uix/eidos/components/callout';
export type Config = {
intent?: CalloutProps['intent'];
color?: CalloutProps['color'];
role?: CalloutProps['role'];
style?: string;
title?: boolean;
icon?: boolean;
content?: boolean;
customTitle?: string;
customIcon?: boolean;
ariaLabel?: string;
nested?: boolean;
peer?: boolean;
dir?: 'ltr' | 'rtl';
reduce?: boolean;
viewportWidth?: number;
};
export type Controls = {
intent: (next: CalloutProps['intent']) => void;
color: (next: CalloutProps['color']) => void;
role: (next: CalloutProps['role']) => void;
title: (next: boolean) => void;
icon: (next: boolean) => void;
customTitle: (next: string | undefined) => 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 Callout from '$uix/eidos/components/callout';
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 intent = $state(initial.intent);
// svelte-ignore state_referenced_locally
let color = $state(initial.color);
// svelte-ignore state_referenced_locally
let role = $state(initial.role);
// svelte-ignore state_referenced_locally
let title = $state(initial.title ?? true);
// svelte-ignore state_referenced_locally
let icon = $state(initial.icon ?? true);
// svelte-ignore state_referenced_locally
let customTitle = $state(initial.customTitle);
const effectiveColor = $derived(intent && intent !== 'neutral' ? intent : (color ?? 'neutral'));
onMount(() =>
onControls({
intent: (next) => (intent = next),
color: (next) => (color = next),
role: (next) => (role = next),
title: (next) => (title = next),
icon: (next) => (icon = next),
customTitle: (next) => (customTitle = next)
})
);
</script>
{#snippet titleChildren()}{customTitle}{/snippet}
{#snippet iconChildren()}<span data-audit-glyph>★</span>{/snippet}
<div
id="audit-callout-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">
<p id="audit-callout-external">Maintenance window</p>
<button id="audit-callout-before" type="button">Before</button>
<Callout
id="audit-callout"
class="audit-callout"
data-probe="callout"
{intent}
{color}
{role}
style={initial.style}
aria-label={initial.ariaLabel}
>
{#if icon}<Callout.Icon
class="audit-icon"
data-probe="icon"
children={initial.customIcon ? iconChildren : undefined}
/>{/if}
{#if title}<Callout.Title
class="audit-title"
data-probe="title"
children={customTitle === undefined ? undefined : titleChildren}
/>{/if}
{#if initial.content !== false}
<Callout.Content id="audit-callout-content" class="audit-content" data-probe="content">
Ancillary information about the maintenance window. Read the documentation before
proceeding.
{#if initial.nested}
<Callout id="audit-callout-nested" intent="risk">
<Callout.Icon /><Callout.Title />
<Callout.Content>Nested warning</Callout.Content>
</Callout>
{/if}
</Callout.Content>
{/if}
</Callout>
{#if initial.peer}<Callout id="audit-callout-peer" intent="affirm"
><Callout.Title /><Callout.Content>Peer</Callout.Content></Callout
>{/if}
<button id="audit-callout-after" type="button">After</button>
<div
id="audit-callout-palette-control"
aria-hidden="true"
style={'block-size:16px;background:var(--color-' +
effectiveColor +
'-track);color:var(--color-' +
effectiveColor +
'-text);border:3px solid var(--color-' +
effectiveColor +
'-solid)'}
>
palette
</div>
</div>
</div>

@ -0,0 +1,56 @@
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 './callout-audit-harness.svelte';
export function renderCallout(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-callout')!,
title: () => target.querySelector<HTMLElement>('#audit-callout > [data-callout-title]'),
icon: () => target.querySelector<HTMLElement>('#audit-callout > [data-callout-icon]'),
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/callout-' + name + '.png' });
}
},
async dispose() {
await unmount(component);
uix.dispose();
target.remove();
}
};
}
export type View = ReturnType<typeof renderCallout>;
export async function visit(fn: (view: View) => Promise<void>, initial: Config = {}) {
await page.viewport(initial.viewportWidth ?? 1100, 900);
const view = renderCallout(initial);
try {
await view.ready();
await fn(view);
} finally {
await view.dispose();
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.