diff --git a/docs/process/HANDOFF-uix-quality-2026-10-02.md b/docs/process/HANDOFF-uix-quality-2026-10-02.md index 970b6b004..15b893cc8 100644 --- a/docs/process/HANDOFF-uix-quality-2026-10-02.md +++ b/docs/process/HANDOFF-uix-quality-2026-10-02.md @@ -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. diff --git a/docs/process/PLAN-uix-quality-2026-09-19.md b/docs/process/PLAN-uix-quality-2026-09-19.md index d695bfb2f..294275491 100644 --- a/docs/process/PLAN-uix-quality-2026-09-19.md +++ b/docs/process/PLAN-uix-quality-2026-09-19.md @@ -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. diff --git a/docs/process/audit-uix-2026-09-19/banner-callout.md b/docs/process/audit-uix-2026-09-19/banner-callout.md index de192be08..8d8643ac5 100644 --- a/docs/process/audit-uix-2026-09-19/banner-callout.md +++ b/docs/process/audit-uix-2026-09-19/banner-callout.md @@ -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. diff --git a/docs/process/audit-uix-2026-09-19/callout-native.md b/docs/process/audit-uix-2026-09-19/callout-native.md new file mode 100644 index 000000000..b79dd57d4 --- /dev/null +++ b/docs/process/audit-uix-2026-09-19/callout-native.md @@ -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. diff --git a/docs/process/audit-uix-2026-09-19/findings-register.md b/docs/process/audit-uix-2026-09-19/findings-register.md index b8a85be32..74cb824c2 100644 --- a/docs/process/audit-uix-2026-09-19/findings-register.md +++ b/docs/process/audit-uix-2026-09-19/findings-register.md @@ -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. diff --git a/src/uix/eidos/components/callout/README.md b/src/uix/eidos/components/callout/README.md index b99c6f225..dd437d689 100644 --- a/src/uix/eidos/components/callout/README.md +++ b/src/uix/eidos/components/callout/README.md @@ -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. ` @@ -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 | diff --git a/src/uix/eidos/components/callout/callout-native.svelte.test.ts b/src/uix/eidos/components/callout/callout-native.svelte.test.ts new file mode 100644 index 000000000..40a713b12 --- /dev/null +++ b/src/uix/eidos/components/callout/callout-native.svelte.test.ts @@ -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 } + )); diff --git a/src/uix/test/callout/callout-audit-harness.svelte b/src/uix/test/callout/callout-audit-harness.svelte new file mode 100644 index 000000000..3b7c8a5df --- /dev/null +++ b/src/uix/test/callout/callout-audit-harness.svelte @@ -0,0 +1,137 @@ + + + + +{#snippet titleChildren()}{customTitle}{/snippet} +{#snippet iconChildren()}★{/snippet} +
+
+

Maintenance window

+ + + {#if icon}{/if} + {#if title}{/if} + {#if initial.content !== false} + + Ancillary information about the maintenance window. Read the documentation before + proceeding. + {#if initial.nested} + + + Nested warning + + {/if} + + {/if} + + {#if initial.peer}Peer{/if} + + +
+
diff --git a/src/uix/test/callout/callout-audit-view.ts b/src/uix/test/callout/callout-audit-view.ts new file mode 100644 index 000000000..1117f2e46 --- /dev/null +++ b/src/uix/test/callout/callout-audit-view.ts @@ -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((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ); + return { + uix, + target, + controls: () => controls, + root: () => target.querySelector('#audit-callout')!, + title: () => target.querySelector('#audit-callout > [data-callout-title]'), + icon: () => target.querySelector('#audit-callout > [data-callout-icon]'), + node: (selector: string) => target.querySelector(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; +export async function visit(fn: (view: View) => Promise, initial: Config = {}) { + await page.viewport(initial.viewportWidth ?? 1100, 900); + const view = renderCallout(initial); + try { + await view.ready(); + await fn(view); + } finally { + await view.dispose(); + } +}