You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/COMPONENT_COHERENCE_AUDIT.md

49 KiB

title: Auditoría de coherencia de componentes — eidos type: audit date: 2026-06-16 scope: src/uix/eidos/components/* (excl. words · palabras · chronos) method: grep + Read dirigidos (sin agentes, por instrucción del usuario) status: FASE 1 completa. FASE 2 en curso — ver "hand-off 2026-06-24" (al final). Hechos previos: action components, font 1:1, 2A radius, gap canónico, popover color-picker, faults de selects (combobox/search-field/select). 2026-06-24: barrido de langs (capa eidos — 6 wrappers de la cascada IconButton + 2 divergencias de contrato + onion/cropper/time-picker), DIM 5E (segmentos date/time/color — flicker del borde + estado activo unificado vía 5A), DIM 5B (code-block/kbd→ControlVariant, image-picker md, switch stale, link 'default' se queda). CONTINUAR por: DIM 1 composición (Lote A closes → IconButton+SvgX; Lote B 6 selects nativos → Select).

Auditoría de coherencia de componentes — eidos

Catálogo de incoherencias a nivel de componente: partes cableadas a mano en vez de componer el ecosistema, literales que se saltan el tema, coherencia de contrato y verificación de morfo. Excluidos: words, palabras, chronos. 116 componentes barridos.

Resumen ejecutivo

Dimensión Veredicto Hallazgos
1. Composición 🔴 grande 6 <select> nativos · ~16 componentes reimplementan chrome de botón-comando · ~14 ya correctos (referencia)
2. Literales que se saltan el tema 🟡 medio ~250 literales px/rem; pocos 🔴 bypass real, muchos 🟡 off-scale interno, mayoría 🟢 idiomas legítimos
3. Coherencia de contrato 🟢 limpio eidos-lint: 0 invalid en los 116 — el typed-builder + lint funcionan
4. Verificación de morfo 🟢 casi limpio morfos compilan; unused informativo; 1 ítem: corner-shape documentado-no-materializado
5. Homogeneidad transversal 🔴 alto focus-ring 2 mecanismos + a11y forced-colors · fondo de fields no homogéneo (editable/tags-input raised, textarea fantasma, combobox hardcode) · segmentos date/time/color estado activo divergente + anillo + flicker al teclear · modelo de anchura (block-opt-in vs 100%-siempre → fields fijos desproporcionados) · nits API

El problema real es composición (dim. 1), la higiene de literales (dim. 2) y el focus-ring no homogéneo (dim. 5). Contrato y morfo (dim. 3/4) están sanos — no se fabrican problemas ahí.


Decisiones de diseño (cerradas con el usuario, 2026-06-16)

  1. Clave de clasificación = la FAMILIA del evento en el morfo + el ESTADO del part (no la impresión visual). Anclado al libro: la familia es intrínseca al acto y fija su firma perceptual; <Button> empaqueta contact.activate.
  2. Política ternaria por parte interactiva:
    • Botón-comando sin estado (close · clear · prev/next · inc/dec · step · download · action) → componer <Button> (familia contact.activate; la consecuencia de dominio —emerge.close, shift.navigate— la cablea el consumidor).
    • Superficie CON estado (porta aria-selected/expanded/current; familia commit/emerge/shift sobre el control) → identidad estructural (data-button + tokens del recipe Button), mantiene su role/aria/keyboard/sema. Precedente: toggle-group (data-toggle).
    • Chrome propio (calendar-day = gridcell) → bespoke pero consumiendo tokens.
  3. IconButton es un primitivo público del framework (no adorno; MUI/Radix/ Chakra lo tienen). Vía (a): se quita iconOnly del API público de Button (pasa a modo interno), IconButton es EL componente icon-only — compone Button por dentro (hereda variant/size/intent/color/rounded/loading + morfo + sema; sin morfo nuevo), añade aria-label obligatorio por tipo + el glifo como contenido + el layout cuadrado. Es agnóstico al glifo. Se migran los 14 <Button iconOnly> actuales (corte limpio, sin shim). NO se hace CloseButton ni un componente-por-glifo (el glifo es prop/children).
  4. Fuente de glifos = por animabilidad, no por capa:
    • svg/ = glifos de affordance propios y animables/acoplables a estado (chevron rota, check/dash dibujan vía pathLength+data-svg, dot, y el × que se animará al cerrar). Tuned, hooks data-svg.
    • icon/ (1696 lucide) = glifos de contenido estático para el consumidor / chrome estático puntual. No tuneados.
    • Regla: ¿se animará o acopla a estado? → svg/. ¿Contenido estático? → icon/ lucide. → faltan SvgX (+ SvgPlus/SvgMinus) en svg/.
  5. Regla de radio theme-driven: todo radio —incluido full y el default— resuelve a --radius-*; cero literales (9999px → var(--radius-full)). Aplica a magnitud (rounded) y forma (corner-shape).

DIMENSIÓN 1 — Composición

1A — <select> NATIVO → componer <Select> (🔴 estructural)

<select><option> nativo donde el ecosistema tiene <Select> compuesto (Trigger/Value/Content/Item…). Migrarlo cambia morfo (archetype input → composite), soma (render <Select.*>), DOM y a11y.

Componente Parte Morfo hoy Fichero soma
calendar month-select, year-select archetype input, element select soma/.../calendar-month-select.svelte + year
range-calendar month-select, year-select idem soma/.../range-calendar-{month,year}-select.svelte
color-field format-select input/select soma/.../color-field-format-select.svelte
color-picker format-select input/select soma/.../color-picker-format-select.svelte

Síntomas del chrome nativo a resolver al migrar (observados en color-field, 2026-06-16):

  • Con appearance: auto, al estar activo/enfocado el <select> pinta el borde/outline negro nativo del navegador — no tiene sentido visual y rompe la coherencia con el resto del field. Desaparece al componer <Select> (que usa el focus-ring del sistema, themeable). Vale para los 6 sitios.
  • (Histórico) se le había aplicado por error el resaltado de segmento-activo (--field-segment-active-*) en :focus-visible; revertido a su anillo propio el 2026-06-16. La migración a <Select> lo hace moot.

1B — Botón-comando que REIMPLEMENTA chrome → componer <Button> (🔴)

Parte stateless de comando con border/bg/hover/focus-visible/disabled reescrito en su CSS, en vez de componer <Button> vía soma child. Familia del part = contact.activate (la consecuencia de dominio la cablea el consumidor).

Componente Parte(s) Confirmado
calendar prev-button, next-button ✓ leído (--calendar-control-* chrome completo)
range-calendar prev/next ✓ análogo a calendar
dialog close ✓ no compone Button
drawer close ✓ no compone Button
popover close ✓ no compone Button
banner close ✓ no compone Button
toast close ✓ no compone Button
float-panel close (+ controles) ✓ no compone Button
pagination prev/next nav candidato (page-items van a 1D)
stepper step nav candidato
spin-field increment / decrement candidato (ver Lote 0: spin-field es el visual compartido)
editable edit / submit / cancel candidato
file-upload browse / trigger candidato
image-picker trigger / controles candidato
color-picker eyedropper / controles de acción candidato
tags-input clear (remove-tag → 1D, es chip) candidato
color-field clear candidato

"candidato" = grep marca chrome de botón sin composición de Button; confirmar el part exacto al abrir el wrapper en Lote A.

1C — YA correctos (componen <Button>) — referencia/plantilla

alert-dialog (action/cancel) · button · card (footer) · carousel (prev/next) · clipboard (trigger) · combobox (clear) · dropdown-menu (trigger) · link-preview (trigger) · navigation-menu (link/trigger) · picker-shell (close/clear/cancel) · qr-code (download) · search-field (clear) · table (row-detail) · tree-grid (expand).

Plantilla canónica: components/carousel/carousel-prev-trigger.svelte (<Button iconOnly variant='ghost' rounded='full'> + <SvgChevron> vía soma child, size desde contexto).

1D — Superficie CON estado → identidad estructural (🟡 polish, no bug)

Portan estado de dominio (aria-selected/expanded/current/pressed); su familia es commit/emerge/shift SOBRE el control. No componen Button; si quieren su look → data-button + tokens del recipe (vía toggle-group). Si tienen chrome propio coherente, se quedan bespoke-tokenizados.

Componente Parte Familia Acción
toggle-group item commit/toggle ✓ ya identidad estructural (data-toggle)
tabs trigger commit/select identidad estructural (look de Button) — opcional
accordion header/trigger emerge bespoke disclosure (chrome propio)
pagination page-item commit identidad estructural — opcional
calendar / month-grid / year-grid day/cell commit/shift bespoke gridcell (tokenizado)
listbox / select / combobox / menubar / context-menu / dropdown-menu item commit bespoke item (tokenizado)
radio-cards / rating-group card/item commit bespoke (tokenizado)
tree-view / tree-grid node commit/emerge bespoke (tokenizado)

Indicador vs botón discreto (chevron de expand) — verificado 2026-06-18. El chevron de expand es IconButton SOLO cuando es un botón DISCRETO (tree-grid expand-trigger: un botoncito aparte en la fila, que compone <Button iconOnly> → migrado a <IconButton>). Cuando es un indicador DECORATIVO —<SvgChevron> que rota por data-state dentro de una superficie clicable mayor— NO es IconButton:

  • tree-view BranchIndicator: chevron suelto; el control es toda la rama.
  • accordion: <span data-accordion-indicator aria-hidden><SvgChevron></span> dentro de la cabecera; el control es toda la <Accordion.Trigger>.

Mismo glifo (SvgChevron) en los tres → coherencia de glifo. Lo que cambia es el modelo de interacción (toggle de superficie entera vs botón discreto). La regla: chevron como indicador de estado ≠ botón icon-only. Por eso tree-view y accordion quedan fuera de la cascada IconButton, correctamente.


DIMENSIÓN 2 — Literales que se saltan el tema

~250 literales px/rem en value-position (excl. var(), 0). Tres clases:

2A — 🔴 Bypass real (el token EXISTE y se ignora)

Fichero Literal Debe ser
button/button.css:202 --_button-radius: 9999px (rounded='full') var(--radius-full)
grid-list/grid-list.css:203 border-radius: 0.2rem var(--radius-sm) (o token nombrado)
navigation-menu/navigation-menu.css:292 border-radius: 1px (indicador) var(--radius-sm) o token del indicador

2B — 🟡 Off-scale interno (NO existe token; decidir por doctrina anti-mágicos)

Micro-escalas component-internas sin equivalente en la escala. Opción: snap a escala existente, o token de recipe nombrado y justificado.

  • Escalas de tamaño internas: spinner (--_spinner-size 0.75–2.5rem + thickness 1.5–4px), carousel (--_carousel-indicator-size 0.375–0.75rem), link-preview (--_link-preview-min-width 11–22rem), color-picker (--cp-checker-cell: 6px).
  • Focus ring offset: outline-offset: 1px|2px recurre en muchos (link, button, navigation-menu…) → candidato fuerte a token --focus-ring-offset.
  • Micro-gaps/paddings sub-escala (<--space-1=4px): gap: 1px|2px, padding: 2px (time-range-picker, command, navigation-menu) → token --space-px/--space-0.5 o aceptar.
  • Geometría de barra/indicador: block-size: 2px (navigation-menu, command).
  • Underline: text-decoration-thickness: 1px, text-underline-offset: 2px|3px.

2C — 🟢 Idiomas legítimos (NO tocar)

  • sr-only: width:1px; height:1px; margin:-1px (button, link, command, time-range-picker…) — patrón canónico de visually-hidden.
  • em-relativos: 0.85em, 1.125em, 0.875em, 0.25em — escalan con la fuente local; no son bypass.
  • 50% círculos: cropper handle, search-field clear, spinner — idioma de círculo perfecto sobre elemento cuadrado.
  • Deltas de keyframe de motion: translateY(4px), translateX(12px), scale(0.985) — offsets de animación.
  • box-shadow: 0 0 0 9999px (cropper mask) — hack de overlay, no es radio.

Top offenders por nº de literales (para priorizar lectura en Lote D)

link-preview 17 · navigation-menu 14 · time-range-picker 13 · spinner 12 · link 11 · button 10 · tooltip 9 · carousel 9 · command 8 · color-picker 8 · tree-grid 7 · password-field 7 — (la mayoría caen en 2B/2C; el 🔴 real es chico).


DIMENSIÓN 3 — Coherencia de contrato

scripts/eidos-lint-all.ts: invalid = 0 en los 116 componentes. Ningún selector eidos targetea un attr con valor que el morfo no declara. El typed-builder (semaSelector) + el lint cumplen su función. Sin hallazgos.

eidos-only (selectores que el morfo no declara pero son legítimos: pseudo-estados, cross-component como [data-popover-content] [data-year-grid], attrs estructurales) y unused (el morfo declara attrs que el CSS no estiliza) son informativos, no bugs.


DIMENSIÓN 4 — Verificación de morfo

  • Todos los morfos compilan limpios (el lint los carga sin error).
  • unused alto en algunos (drawer 16, stepper 28, field 22, css-field 18, number-field 18): el morfo declara attrs que su CSS no estiliza. Casi siempre legítimo — los consume soma/sema o son state-only. css-field/number-field tienen CSS propio vacío porque delegan en el visual compartido spin-field (identidad estructural). 🟡 verificar puntualmente que ninguno sea declaración muerta — baja prioridad.
  • corner-shape (Button types.ts: rounded/continuous/cut/scoop): documentado como prop pero sin materialización encontrada en button.css. Posible prop-sin-implementar = incoherencia de contrato. 🟡 verificar/implementar.

DIMENSIÓN 5 — Homogeneidad transversal (API + visual)

Consistencia del vocabulario y del aspecto entre componentes — lo que hace que la librería se sienta "de una pieza". Verificado por grep de types.ts + CSS.

5A — Focus-ring NO homogéneo (🔴, headline)

El valor del foco es casi uniforme (--color-focus-ring = alias de --focus-ring-color; los ~18 --{c}-focus-shadow son copias del composite --focus-ring). Lo que rompe es el mecanismo:

Mecanismo Componentes Nota
outline: var(--focus-ring-width) solid var(--focus-ring-color) ~22 (badge, card, carousel, drawer, form, listbox, menubar, popover, table, tree-, virtual-…) el estándar
box-shadow: var(--{c}-focus-shadow) (composite) ~18 (checkbox, radio-group, radio-cards, select, slider, date-field, time-field, tags-input, toolbar, pagination, stepper, rating-group, breadcrumb, color-field, editable, file-upload, switch…) muere en forced-colors (HCM) = regresión a11y
outline: var(--border-width-medium) solid var(--color-primary-solid) button, link, code-block 3ª convención (ancho + color distintos a los tokens de focus)
outline … var(--color-focus-ring) cropper, image-adjustments, image-picker token-alias redundante

→ El anillo de foco se ve y se comporta distinto según el componente. Fix: unificar todo a outline: var(--focus-ring-width) solid var(--focus-ring-color); eliminar los --{c}-focus-shadow, la 3ª convención y el alias --color-focus-ring. Cierra además el a11y de forced-colors (deferido en el sprint de theming).

Familia FIELD migrada 2026-06-23 (a petición del usuario: «solo fields ahora» + sin hueco). El composite tiene --focus-ring-inner-width: 0, así que el ring es visualmente equivalente al outline; el outline-offset se deja en 0 (no --focus-ring-offset) porque con outline el gap es transparente (muestra el fondo) y el usuario lo vio como hueco — pegado queda limpio. Migrados a outline: var(--focus-ring-width) solid var(--focus-ring-color); outline-offset: 0: field · date/time/color-field · search · select · combobox · editable · spin-field (+number/css) · tags-input · pin-input · textarea · password-field (ring en el ::before chrome) · color-picker-trigger · y los campos de fecha/hora embebidos en date-range-picker / time-picker / time-range-picker. 11 tokens --{c}-focus-shadow huérfanos eliminados del recipe (field-control, spin×2, date/time/color-field, color-picker-trigger, search, password×2, select). orphan test PASA; check sin errores nuevos; verificado en navegador (field + date-field: anillo pegado sin hueco).

Huérfanos del contract test (consumed-vars) cerrados 2026-06-23 — al migrar el focus salieron 3 vars consumidas-no-declaradas (test distinto del orphan): scroll-area: --scroll-area-auto-max-block → declarado en el recipe (default none, el consumidor —p.ej. command— lo override); menu-dial: --menu-dial-* (8 vars: arc- span/start, count, gap, i, offset, radius, backdrop-bg, stagger-step) → menu-dial es CSS-puro sin recipe, así que todas sus vars internas se privatizaron a --_menu-dial-* (CSS + los 2 svelte que setean count/i/radius inline). Verificado: stagger calcula (transition-delay 0.12s = i×28ms). Único fallo restante del contract = palabras (vedado: words/palabras/chronos no se tocan). Resto (NO-field) migrado 2026-06-23 — mismo patrón (outline + offset 0): breadcrumb, calendar (+month-grid/year-grid/range-calendar reúsan su token), pagination, rating-group, toolbar, file-upload, tag-group, stepper, editable (input editing: conserva control-shadow + outline), tags-input (item activo + triggers); checkbox/radio-group/radio-cards/switch (--{c}-focus-ring → outline); button YA era canónico, code-block + link (3ª convención) → canónico; cropper/image-picker/ image-adjustments (alias --color-focus-ring → --focus-ring-color); slider + time-picker/time-range clock thumbs (outline + conserva el thumb-shadow). 13 tokens --{c}-focus-shadow huérfanos eliminados del recipe. color-field-segment (inset) NO se toca. words/palabras/chronos vedados — words-focus-shadow se rozó por error de ancla y se restauró. Contract test: pasa salvo palabras (vedado). Deuda fina CERRADA 2026-06-23: los composites muertos --checkbox-focus-ring / --radio-group-focus-ring + sus 4 sub-tokens cada uno (-offset/-width/-color/ -surface), y el privado --_switch-focus-ring + sus 4 sub-tokens del recipe — todos ELIMINADOS (sin consumidor tras migrar a outline; el test no los detectaba por substring). field-control-trigger era otro outlier 3ª-convención (escapado del sweep) → migrado al canon. El alias --color-focus-ring NO se elimina: lo consume palabras (vedado); los border-focus que lo usan (field/spin/search/ password/dialog/textarea) son funcionalmente correctos (alias ≡ --focus-ring-color), así que quedan como están — el alias persiste solo para no romper el track vedado.

5D — Fondo de la familia field/input NO homogéneo (🔴) — RESUELTO 2026-06-23

Re-auditado empíricamente (el cuadro original estaba parcialmente STALE):

  • field era el ÚNICO outlier de verdad — default variant='outline' (fondo TRANSPARENTE vía --field-control-ghost-bg) mientras los otros 12 (select, textarea, date/time/color-field, search/password/number/css-field, combobox, editable, tags-input) default variant='surface' → surface-default. Como muchos campos de texto se construyen con <Field>, se veían TODOS transparentes junto a un select con fondo. Fix: field default 'outline' → 'surface' (field.svelte + types.ts @default). Verificado: field control bg = surface-default, idéntico al select.
  • editable / tags-input: el audit decía surface-raised → STALE. Medido: --editable-control-bg y --tags-input-control-bg YA son surface-default (el trigger-bg raised que confundía es de un botón trigger, no del control).
  • textarea: el fantasma --color-surface YA estaba arreglado (default).
  • Color-field embebido en el color-picker (reportado por el usuario): el override [data-color-picker-content] [data-color-field-input] lo pintaba surface-overlay + border-color: transparent = EXACTAMENTE el fondo del popover (overlay) → el campo se veía transparente (se fundía). Fix: quitado el override de bg/border → usa su propio --color-field-bg (surface-default) + borde, que CONTRASTA con el popover. Verificado (nowDistinctFromPopover: true, screenshot). Era el único input que se fundía con su contenedor (los demás surface-overlay son :hover/headings, correctos).

5D-bis — Label de la familia field: font-size canónico (RESUELTO) + color (PENDIENTE)

  • font-size: canon = un paso por debajo del control, escala (field/date/time/ color-field usan calc(... - (font-md - font-sm))). color-picker era el outlier: font-size: var(--font-size-sm) FIJO + font-weight: medium. Fix: → calc(var(--_color-picker-trigger-font-size) - (font-md - font-sm)) (escala sm12/md14/lg16) + weight heredado (regular). Verificado.
  • color (PENDIENTE — decisión de canon): field label = content-primary; date/time/color-field + color-picker = content-secondary. Diverge. Falta decidir el canon (primary vs secondary) antes de unificar.

5E — Segmentos date/time/color: estado activo divergente + anillo + flicker (🔴) — RESUELTO 2026-06-24 (ver hand-off al final)

Afecta date-field, time-field, color-field (estructura idéntica) → se propaga a date-picker/time-picker/date-range-* que los embeben.

Estado activo del segmento — divergente y con borde:

Componente Fondo activo Anillo Peso
date-field accent-element (fuerte) inset ring (--ring-inset-width) 600
time-field accent-track (sutil) --time-field-segment-focus-shadow —
color-field accent-track (sutil) --color-field-segment-focus-shadow —

→ Los tres llevan anillo; date lo enmascara con fondo fuerte, time/color lo muestran. Objetivo (canónico): el segmento activo se resalta con fondo y sin borde/anillo. Fix: quitar el box-shadow (inset ring + --{c}-segment-focus-shadow) en los tres; unificar a UN token de fondo (decidir element vs track en browser) + color: accent-text (+ peso consistente). outline: none se queda.

Flicker del borde del campo al incrementar por teclado (bug): [data-{c}-field-input] transiciona border-color+box-shadow, y :has([segment]:focus-visible) aplica borde+shadow al campo entero. Al incrementar, el :focus-visible del segmento parpadea (re-render del segmento en soma → blur/refocus), el :has() salta off→on, y la transición desvanece el borde fuera/dentro = el parpadeo. Fix: quitar border-color+box-shadow de la transition del input (foco instantáneo). Si persiste un blink de 1 frame, raíz secundaria = retención de foco del segmento en soma.

5F — Modelo de anchura: dos defaults opuestos + fields desproporcionados (🔴)

Dos modelos de anchura coexisten:

  • block-opt-in (~10 componentes: button, toggle, toggle-group, carousel, feed, grid-list, listbox, table, tree-grid, tree-view): default content-width, prop block → data-block → inline-size: 100%.
  • 100%-siempre (familia field: date/time/color-field, number, css, search, password, combobox, select, textarea, field, + pickers): hard-codean inline-size: 100% en root + input, sin opt-out (no hay prop block).

→ Los fields de contenido casi fijo (date/time/color/pin) a 100% son desproporcionados — un 12/31/2026 no necesita la columna entera; los pickers (date/time) heredan el 100% del contenedor aunque su contenido sea estático. (Los max-content existentes son del popover del calendario, no del trigger.)

Fix (alinear al modelo del propio sistema): la familia field adopta el patrón block —

  • Contenido fijo (date/time/color-field, pin): default inline-size: max-content (segmentos + icono + padding); block → 100%.
  • Contenido variable (text/search/number/combobox/select/password/textarea): decidir — mantener un ancho por defecto razonable (no colapsar vacío) + block, o conservar 100% (convención de columna de formulario).

Decisión de diseño — CERRADA (2026-06-18): full-width para TODA la familia. El usuario rechazó max-content/block para los fields. La anchura la gobierna el contenedor (columna de formulario, Box/Stack con max-width), no el campo — convención dominante (Chakra/Bootstrap/Radix Themes/shadcn = full-width por defecto; MUI con fullWidth opt-in es el outlier). inline-size: 100% se queda en toda la familia, sin opt-out por campo. Sin acción de código: el estado actual ya es full-width homogéneo (verificado en el audit de anchura del 2026-06-18; color-field era la única excepción —un fit-content temporal— ya revertido).

5B — Nits de API (🟡) — CERRADO 2026-06-24 (decisiones del usuario, ver hand-off)

  • image-picker default size — ✅ 'sm'→'md' (decisión: consistencia).
  • switch — ✅ STALE: ya NO declara SwitchVariant ni prop variant (solo SwitchSize). Variant-less correcto (color por intent+color). Sin acción.
  • link variant 'default' — ✅ se MANTIENE (decisión: es la base con significado; renombrar = churn de API por bajo valor).
  • code / code-block / kbd: ✅ code-block + kbd 'surface'|'outline'|'ghost' aliasados a ControlVariant (duplicaban literales → doctrina "no unions locales con literales duplicados"); code queda local ('plain'|'soft'|'outline' no mapea a arquetipo; §19 permite variants component-specific).

5C — Sano (verificado, NO findings)

  • Variants: la mayoría usa alias-arquetipo canónicos (ControlVariant, SelectionVariant, ChipVariant, MarkerVariant) → homogéneo por construcción.
  • Defaults: size='md' universal (salvo image-picker); variant por familia consistente (inputs 'surface', selección 'solid', chips 'soft'; field/accordion 'outline' = rol de wrapper, defendible).
  • Eje color/intent: coherente y documentado — color (jerarquía) en todos; intent (evaluativo) solo donde el componente contiene Buttons y se lo reenvía (carousel/table/clipboard/dropdown-menu/alert-dialog: "flows to Button", color aplica bajo intent='neutral'). banner intent-only = componente de feedback. NO es deriva.
  • Transición: 17 consumen var(--duration-fast) directo; los --{c}-transition-duration encadenan a --duration-* desde el recipe → valor homogéneo (solo varía la indirección). NO finding.
  • Disabled: 27 consumen var(--opacity-disabled) (unificado en el sprint de theming); los 0.X restantes son otras opacidades (muted/overlay) ya cubiertas en dim. 2. NO finding nuevo.

PLAN DE REMEDIACIÓN POR LOTES

  • Lote 0 — primitivos compartidos (enabler):
    1. svg/: SvgX (+ SvgPlus/SvgMinus para inc/dec) — animables, pathLength, data-svg, hermanos de chevron/check/dash/dot.
    2. IconButton (eidos): compone Button, aria-label requerido, agnóstico de glifo, layout icon-only (data-icon-only). Sin morfo nuevo.
    3. Button: quitar iconOnly del API público (modo interno disparado por IconButton) + rounded='full' → var(--radius-full).
    4. Migrar los 14 <Button iconOnly> → <IconButton>. Piloto verificado: carousel.
  • Lote A — Button-consumer (patrón carousel-prev-trigger, compone Lote 0): los 1B. calendar/range-calendar prev/next → IconButton+SvgChevron; closes (dialog/drawer/popover/banner/toast/float-panel) → IconButton+SvgX. Borrar el chrome --*-control-*/--*-close-* redundante del CSS.
  • Lote B — <select> → <Select> (1A, estructural): morfo (input→composite)
    • soma (render <Select.*>) + a11y + borrar chrome de select. 6 sitios.
  • Lote C — identidad estructural (1D opcional): tabs/pagination al look de Button vía data-button (precedente toggle-group), manteniendo su sema.
  • Lote D — literales + contrato + doctrina glifos: 2A 🔴 (button/cropper 9999px→--radius-full, grid-list, nav-menu); decidir 2B (token de focus-offset, micro-space, escalas internas → recipe tokens); verificar/implementar corner-shape; documentar la regla svg/-vs-icon/ en sus READMEs.
  • Lote E — homogeneidad (dim. 5):
    • Focus-ring: unificar a outline: var(--focus-ring-width) solid var(--focus-ring-color) + outline-offset en TODO el set (migrar los ~18 box-shadow + la 3ª convención de button/link/code-block + el alias --color-focus-ring); cierra el a11y de forced-colors.
    • Fondo de fields (5D): unificar el surface de la familia input a --color-surface-default; editable/tags-input raised→default; arreglar el token fantasma de textarea (--color-surface → --color-surface-default); combobox vía token de recipe.
    • Segmentos date/time/color (5E): quitar el anillo (box-shadow) del segmento activo en date/time/color-field, unificar el fondo de resalte a un token; quitar border-color+box-shadow de la transición del input (elimina el flicker del borde al incrementar por teclado).
    • Modelo de anchura (5F): ✅ CERRADO (2026-06-18) — full-width para toda la familia (container-driven). Sin acción de código: el estado actual ya es homogéneo.
    • Nits API (5B): image-picker default md, switch variant colgante, renombrar link 'default', normalizar variants de code/code-block/kbd.

Verificación por lote

npm run check 0 errores · npx vitest run src/uix/eidos · generate:eidos-css si cambian recipes · eidos-lint sin invalid nuevos · browser (Chrome MCP): paridad visual (hover/focus/disabled) + a11y (foco/teclado) en selects migrados.


Estado de ejecución — hand-off 2026-06-18

Lote 0 — IconButton + cascada iconOnly→IconButton

Hecho + verificado:

  • Primitivos: svg/SvgX · SvgPlus · SvgMinus (animables, pathLength). IconButton (components/icon-button/): compone <Button> (hereda variant/size/intent/color/rounded/shape/loading + morfo + sema de Button; sin morfo nuevo), aria-label obligatorio, glifo como children, iconOnly interno. Button: rounded='full' 9999px → var(--radius-full).
  • Migrados (patrón: glifo→children, label→aria-label, fuera iconOnly): carousel (prev/next, piloto) + los 4 de ① — tree-grid-expand, table-row-detail, search-field-clear, combobox-clear. Compile 0-nuevos + medidos en navegador.

Gotchas (releer antes de seguir la cascada):

  • Orden de carga CSS: migrar Button→IconButton mete una indirección de import (consumidor→IconButton→Button) que voltea el orden de carga → rompe empates de especificidad con [data-button]{position:relative} (las flechas del carousel se descolocaron, relative ganó a absolute). Fix: subir la especificidad del posicionado del consumidor con un ancestro ([data-carousel] [data-…-trigger], 0,2,0 > 0,1,0). Los clears del campo son flex-items → sin empate.
  • press-squeeze vs centrado por transform: contact-activate (lo dispara TODO Button) aplica press-squeeze (transform: scale(0.96)), que reemplaza un transform: translateY(-50%) de centrado → salto al pulsar. Fix: centrar con la propiedad translate (compone con el scale en vez de borrarlo); quitar overrides :active.

Pendiente del Lote 0:

  • ② condicionales (NO icon-only puro): clipboard-trigger (default texto "Copy") y dropdown-menu-trigger (trigger genérico) reenvían iconOnly opcional → no se migran de golpe. Decisión: render condicional IconButton/Button, o se quedan en Button. Bloquea el cierre (quitar iconOnly del API público de Button).
  • corner-shape (dim 4 / Lote D): Button declara shape + emite data-shape pero button.css NO lo materializa (0 reglas corner-shape) → todo botón/IconButton es solo redondo-arco. SHAPE_ENGINE_RFC §6 lo difiere (adoptarlo = adelantar — decisión del usuario).
  • tree-view + accordion quedan FUERA (chevron = indicador, no botón discreto — ver la nota en Dimensión 1).

Faults de los selects (reportados por el usuario — siguiente bloque)

Componente Faults
Combobox ✅ TODOS RESUELTOS — verificado 2026-06-23 (el doc estaba STALE; cerrados en sprints posteriores) (a) ✅ el provider emite emerge-open/emerge-dismiss (sema canon) · (b) ✅ [data-scroll-area-viewport] tiene padding-block-end · (c) ✅ ya compone <ScrollArea autosize> · (d) ✅ el soma excluye el trigger del dismiss (líneas 594-611, comentario explícito); verificado en vivo: clic-flecha → data-state open→closed en UN solo cambio, NO reabre · (e) ✅ STALE — el default md ya es --font-size-md (1:1) · (f) ✅ gap 6px (data-floating-gap='panel' o sideOffset), no pegado · (g) ✅ data-depth='overlay' + shadow (A1/B2)
SearchField ⚠️ faults NO aplican a este componente — verificado 2026-06-23 El search-field eidos es un input simple (icon + input + clear + loading): NO tiene popup, scroll ni footer. Los faults "scroll→ScrollArea" / "footer" eran del command palette (input + lista + footer), mal-atribuidos aquí. Sin acción.
Select ✅ mitigado 2026-06-23 "item seleccionado: borde y resaltado muy pegados" venía del padding de panel de 4px; el canon de listas subió a 6px hoy (--list-content-padding), dando margen coherente del token. Verificado en vivo (item a 6px del borde, radius concéntrico 4px sobre panel 10px).
Langs (REGLA transversal) ✅ eidos cubierto 2026-06-24 cero hardcodes — las strings (aria-labels…) van en el morfo del componente vía langs. Hecho: 6 wrappers de la cascada IconButton (carousel prev/next · combobox-clear · tree-grid-expand · table-row-detail · search-field-clear · banner-close) hardcodeaban inglés y pisaban el props['aria-label'] ya resuelto por soma → arreglados con el idiom de drawer-close (resolvedLabel = ariaLabel ?? eidos.langs.ts(idlangref); sin default hardcodeado). Cerró además 2 divergencias del contrato (combobox 'Clear'→'Clear value', table 'Toggle row details'→'Toggle detail'). +onion-menu surface, cropper zoom (4 entradas nuevas), soma time-picker day-period. Deferidos (necesitan superficie langs nueva / solapan con DIM 1): code-block copy (→ componer <Clipboard> en Lote A), avatar-group conteo visible, date-range-picker year/month soma views ("Select year range"/"Previous decade"). Verificado en navegador (ES resuelve).

Orden: combobox → search-field → select → barrido de langs, uno a uno verificando. combobox-arrow/content/viewport son thin-wrappers del soma (la lógica de abrir/cerrar + el scroll viven en soma; el styling en combobox.css).


Estado de ejecución — hand-off 2026-06-22 (CONTINUAR AQUÍ MAÑANA)

Sesión larga; varios cierres + el gap canónico. Para retomar:

Hecho esta sesión

  • Action components shipped (c00bac13): fab, onion-menu, button-group, split-button (eran untracked). Hardcodes → recipes; pasan el contract test (solo palabras queda, excluida). fab = escala off-ladder Material en recipe (las escalas canónicas no la expresan — decisión del user); onion trigger lift/shadow → var(--fab-*); split-button → DropdownMenu customAnchor. Memoria project_action_components_2026-06-22.
  • Font 1:1 en menús (7cf30287): el sweep de campos (md=16) se saltó dos menús + un picker trigger compactos (md→sm). Arreglados: menubar, navigation-menu, color-picker trigger (era doble-compacto md→xs) → 1:1. ⚠️ El grep font-size: var(--font-size-sm) sobre-cuenta (matchea variantes sm/lg correctas): combobox/command/textarea YA eran 1:1 — la nota "combobox md=sm" de §5E / faults está STALE. Verificar el bloque DEFAULT md ([data-X] { sin data-size), no el grep crudo. Font de PANEL de pickers + label de menu-dial = contenido plano (sin eje de size), quedan compactos.
  • 2A radius bypass CERRADO (mismo commit): grid-list 0.2rem→--radius-sm; navigation-menu indicador 1px→--radius-full. (button 9999px ya estaba; cropper 0 0 0 9999px es el hack de máscara, NO tocar.)
  • Gap canónico trigger→panel (1590684c foundation+piloto + d229447a cascade): el sideOffset de floating-ui es un número (no acepta token), así que cada flotante hardcodeaba el suyo (0/4/6/8). Solución: @property --floating-gap que el posicionador compartido lee como OFFSET (token-driven + arrow-safe; NO el margin de split-button, que despegaría la flecha). Tokens: menu 0 / panel --space-1-5. Stamp CONDICIONAL (sideOffset===undefined ? arquetipo : undefined) — el override del consumidor gana. Memoria project_floating_gap_canon_2026-06-22; doc en THEMING.md.

Pendiente (orden sugerido)

  1. ✅ Bug popover color-picker (task_5d1a10ec) — RESUELTO 2026-06-23. Causa raíz: el [data-color-picker-content] fue escrito para la arquitectura ANTIGUA del popover (sin viewport interno) y re-declaraba padding+flex+gap+ overflow-x+scrollbar-gutter en el panel. Tras introducirse el [data-popover-viewport] (padding + scroll + grid) eso causó: doble padding (content 14px + viewport 12px), el flex/gap aplicado al único hijo (el viewport) en vez de a las filas, los constraints > * mal dirigidos, y el grid item sin min-inline-size:0 → scroll horizontal. Fix: el content delega TODO el layout al viewport + picker-shell; solo conserva las vars (checker/accent) + los width-overrides del popover; min-inline-size:0 en shell/body; scrollbar-gutter: stable en el viewport (donde vive el scroll real).
    • HOMOGENEIZACIÓN (corrección de rumbo del usuario): el primer intento preservó el padding PROPIO del color-picker (14px vía --_popover-px/py override + token de recipe) — un error: mantenía la divergencia. Verificado que NINGÚN otro panel flotante (date/time/range pickers, link-preview, tooltip) override el padding del popover — todos heredan el canónico --popover-padding-* (12px md, size-aware). El color-picker era el ÚNICO outlier. Fix final: hereda el padding del popover; tokens content-padding-{sm,md,lg} + content-gap BORRADOS del recipe (eran divergentes/huérfanos); var muerta --_color-picker-content-padding eliminada. Verificado en navegador: matchesPopoverCanon: true (12px == popover), hScroll: false.
    • DOCTRINA: hay DOS clases de superficie flotante con canon propio, cada una internamente homogénea — lista (menús/select/combobox/listbox → --list-content-padding + items con su ritmo) y panel (popover + todo lo que se monta en él: pickers/link-preview → --popover-padding-*, 12px md). Un panel flotante NO declara padding propio; hereda el del popover. Cualquier componente que se monte en [data-popover-content] y override --_popover-px/py es un outlier a corregir.
    • Canon de listas recalibrado 2026-06-23 (a petición del usuario tras el fix): --list-content-padding subió de 4→6px en md; progresión completa 2/4/6/8/10 (xs/sm/md/lg/xl, estrictamente creciente, pasos de 2px). Encaja con la industria (Radix/shadcn/Mantine/AntD 4px · MUI/Bootstrap 8px → 6px = punto medio). Verificado en dropdown + select (6px). El padding de panel se deja en 12px (= Radix Popover size-1, Chakra; 10px quedaría bajo toda la industria). command es outlier JUSTIFICADO (queda fuera del canon de lista): su padding del scroll-viewport (0 22px 4px 8px) alinea los items con el search input, no con el ritmo de lista — NO tocar.
  2. Faults de los selects (sección arriba): combobox (7 faults) → search-field → select → barrido langs cero-hardcodes.
  3. DIM 5A focus-ring (🔴 headline): unificar ~18 box-shadow focus → outline
    • outline-offset; cierra el a11y de forced-colors.
  4. DIM 5D fondo de fields: editable/tags-input raised→default (verificar si deliberado). El fantasma de textarea (--color-surface) YA está arreglado (--color-surface-default) — esa parte de §5D está stale.
  5. DIM 1 composición: Lote A (Button-consumer: closes dialog/drawer/popover/ banner/toast → IconButton+SvgX; calendar/range prev/next) · Lote B (6 selects nativos → <Select>).

Estado de ejecución — hand-off 2026-06-24

Sesión continuando la FASE 2 (sin agentes, vedados words/palabras/chronos). Cerrados: barrido de langs (eidos), DIM 5E, DIM 5B. Pendientes reales abajo.

Hecho + verificado

  • Barrido de langs (cero-hardcodes) — capa eidos cubierta. El patrón roto lo dejó la cascada IconButton: el wrapper eidos inyectaba un default inglés hardcodeado en aria-label y lo pisaba sobre el props['aria-label'] que soma ya resolvía desde el morfo (translationRef). props es Record<string, unknown> → no satisface el aria-label: string requerido por IconButton, así que el fix canónico (idiom de drawer-close) re-resuelve: const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts(idlangref)) → aria-label={resolvedLabel}, sin default hardcodeado. Aplicado a: carousel prev/next · combobox-clear · tree-grid-expand · table-row-detail · search-field-clear · banner-close (este añadió common.buttons.dismiss). Cerró 2 divergencias de contrato (combobox hardcodeaba 'Clear' vs morfo 'Clear value'; table 'Toggle row details' vs 'Toggle detail'). +onion-menu surface (langs ya existía) · cropper zoom (4 entradas nuevas en cropperLangs) · soma time-picker day-period (añadió DAY_PERIOD a TIME_PICKER_LANGS reusando common.time.day-period). Verificado en navegador (ES): carousel → "Diapositiva anterior/siguiente", combobox → "Borrar valor". Deferidos (necesitan superficie langs nueva o solapan con DIM 1): code-block copy ("Copy code to clipboard" → mejor componer <Clipboard> en Lote A, que ya posee clipboardLangs), avatar-group "{count} more" (texto visible), date-range-picker year/month soma views ("Select year range"/"Previous decade" — faltan entradas decade/range en common).

  • DIM 5E (segmentos date/time/color) — RESUELTO. Dos partes:

    1. Estado activo divergente + anillo + peso → ya lo resolvió la migración 5A: existe una capa compartida field/field-segment-state.css (selecciona [data-field-segment], el marcador eidos) que da fondo uniforme --field-segment-active-{bg,text}, sin anillo (outline: none) y sin cambio de peso (el comentario explica: bold ensancharía el segmento ch-based). Los 4 componentes con segmentos (date/time/color-field + date-range-picker) la importan vía su *-field-segment.svelte y delegan (date-field ya NO usa accent-element fuerte + 600). Confirmado: tokens resuelven, marcador presente.
    2. Flicker del borde al incrementar por teclado → arreglado: el input transicionaba border-color (+ box-shadow vestigial, ya no hay focus-shadow tras 5A) y :has(segment:focus-visible) togglea el borde accent; al incrementar, el :focus-visible parpadea (soma re-renderiza → blur/refocus) y la transición desvanecía el borde = flicker. Fix: transition: background … solo (border-color instantáneo; el anillo es outline, ya instantáneo). Aplicado a date-field · time-field · color-field · date-range-picker (input y group, ambos con :has(segment:focus-visible)). time-picker/time-range-picker NO se tocan: su [data-…-trigger] es un botón con :hover que transiciona border/bg/color legítimamente — no es un input segmentado; su time-field embebido ya quedó por time-field.css. Verificado: transitionProperty === 'background' en los 4; renderizan.
    • Nota de tooling: el navegador automatizado del preview no tiene foco de ventana (document.activeElement === segmento pero matches(':focus') === false), así que :focus-visible no engancha vía eval — no se pudo screenshotear el anillo de foco real. Verificación: la regla de foco no la modifiqué (solo la línea de transición) + los tokens resuelven; el flicker es mecánicamente imposible (border-color ya no transiciona).
  • DIM 5B (nits de API) — cerrado con decisiones del usuario.

    • switch: STALE — switch/types.ts ya NO declara SwitchVariant ni prop variant (solo SwitchSize). Correcto variant-less (el color va por intent+color). Sin acción.
    • code-block + kbd → aliasados a ControlVariant: su surface|outline|ghost duplicaba literalmente ControlVariant → doctrina components-README ("no unions locales con literales duplicados"). Cero cambio de valores/CSS/consumidores.
    • code → queda local (plain|soft|outline no mapea a un arquetipo; §19 permite variants component-specific).
    • image-picker → default size 'sm'→'md' (decisión del usuario: consistencia; el size fluye al panel <ImageAdjustments> embebido).
    • link 'default' → se mantiene (decisión del usuario: es la variante base con significado; renombrar = churn de API por bajo valor).

Verificación al cierre

svelte-check: 0 errores en cualquier fichero que toqué (total 59 = baseline; preexistentes en demos untracked / soma file-upload / navigation-menu sideOffset). Suite eidos: 9 fallas todas en ficheros fuera de mi conjunto (spin-field + drawer-close = working-tree heredado de la 5A en curso; palabras = vedado; icon stroke

  • chronos/metrics = untracked/otros tracks) — 0 atribuibles a esta sesión (un test sobre un fichero que no edité tiene input idéntico → resultado idéntico). soma time-picker + morfo: 65/65. Navegador: langs ES + transición de los 4 inputs.

Pendiente (orden sugerido)

  1. DIM 1 composición — Lote A (Button-consumer) — EN CURSO:
    • ✅ popover-close + banner-close → IconButton+SvgX (slices completos: wrapper + types + borrado de chrome CSS + eliminación de tokens recipe huérfanos
      • regen; verificados en navegador, consistentes con dialog/drawer-close).
    • ✅ drawer-close: limpiados los 15 tokens recipe huérfanos que la migración previa dejó + arreglado el manifiesto de component-visual-attrs (drawer + popover componen IconButton → no estampan data-icon-only, sin entrada). Suite eidos 9→8 fallas (las 8 restantes son heredadas: spin-field, palabras, icon).
    • Gotcha confirmado (iconbutton-lote0): migrar a IconButton voltea el orden de carga CSS → [data-button]{margin:0} (igual especificidad, carga después) pisa el positioning del consumidor. Fix: subir especificidad con ancestro ([data-banner] [data-banner-close]).
    • ⏳ Pendiente del Lote A: toast-close (19 refs CSS — chrome más complejo), float-panel-close (chrome COMPARTIDO con action+stage-trigger → migrar los tres o extraer con cuidado), calendar/range prev/next → IconButton+SvgChevron (+ borrar --calendar-control-*), code-block copy → componer <Clipboard>.
  2. DIM 1 — Lote B (estructural): 6 <select> nativos → <Select> (calendar month/year, range-calendar month/year, color-field/color-picker format).
  3. Barrido de langs — cola (deferidos arriba): avatar-group conteo, date-range-picker soma views (entradas decade/range).
  4. DIM 5D-bis (color del label de field): decidir canon primary vs secondary (field usa content-primary; date/time/color-field + color-picker content-secondary).
  5. Higiene del working tree heredado (NO de esta auditoría, pero bloquea el verde de la suite): orphans de spin-field + drawer-close: data-icon-only de la migración 5A en curso. Resolver cuando se cierre/commitee esa migración.

Apéndice — precedentes / plantillas a reutilizar

  • Button-consumer: components/carousel/carousel-prev-trigger.svelte.
  • Identidad estructural: toggle-group (data-toggle + tokens recipe).
  • Visual compartido inc/dec: spin-field (number-field + css-field).
  • Select compuesto: components/select/index.ts.
  • Primitivos SVG: components/svg/ (SvgChevron/Check/Dash/Dot).
  • Doctrina: soma/COMPONENT_GUIDE.md §4 + §12.7; memoria feedback_button_consumer_pattern.

Powered by TurnKey Linux.