49 KiB
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)
- 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>empaquetacontact.activate. - Política ternaria por parte interactiva:
- Botón-comando sin estado (close · clear · prev/next · inc/dec · step ·
download · action) → componer
<Button>(familiacontact.activate; la consecuencia de dominio —emerge.close,shift.navigate— la cablea el consumidor). - Superficie CON estado (porta
aria-selected/expanded/current; familiacommit/emerge/shiftsobre 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.
- Botón-comando sin estado (close · clear · prev/next · inc/dec · step ·
download · action) → componer
IconButtones un primitivo público del framework (no adorno; MUI/Radix/ Chakra lo tienen). Vía (a): se quitaiconOnlydel API público deButton(pasa a modo interno),IconButtones EL componente icon-only — componeButtonpor dentro (hereda variant/size/intent/color/rounded/loading + morfo + sema; sin morfo nuevo), añadearia-labelobligatorio 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 haceCloseButtonni un componente-por-glifo (el glifo es prop/children).- Fuente de glifos = por animabilidad, no por capa:
svg/= glifos de affordance propios y animables/acoplables a estado (chevron rota, check/dash dibujan víapathLength+data-svg, dot, y el×que se animará al cerrar). Tuned, hooksdata-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. → faltanSvgX(+SvgPlus/SvgMinus) ensvg/.
- Regla de radio theme-driven: todo radio —incluido
fully 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-gridexpand-trigger: un botoncito aparte en la fila, que compone<Button iconOnly>→ migrado a<IconButton>). Cuando es un indicador DECORATIVO —<SvgChevron>que rota pordata-statedentro de una superficie clicable mayor— NO es IconButton:
tree-viewBranchIndicator: 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-size0.75–2.5rem + thickness 1.5–4px),carousel(--_carousel-indicator-size0.375–0.75rem),link-preview(--_link-preview-min-width11–22rem),color-picker(--cp-checker-cell: 6px). - Focus ring offset:
outline-offset: 1px|2pxrecurre 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.5o 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).
unusedalto 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-fieldtienen CSS propio vacío porque delegan en el visual compartidospin-field(identidad estructural). 🟡 verificar puntualmente que ninguno sea declaración muerta — baja prioridad.corner-shape(Buttontypes.ts:rounded/continuous/cut/scoop): documentado como prop pero sin materialización encontrada enbutton.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):
fieldera el ÚNICO outlier de verdad — defaultvariant='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) defaultvariant='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-bgy--tags-input-control-bgYA sonsurface-default(eltrigger-bgraised que confundía es de un botón trigger, no del control). - textarea: el fantasma
--color-surfaceYA 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 pintabasurface-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ássurface-overlayson: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-pickerera 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):
fieldlabel =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, propblock→data-block→inline-size: 100%.100%-siempre (familia field: date/time/color-field, number, css, search, password, combobox, select, textarea, field, + pickers): hard-codeaninline-size: 100%en root + input, sin opt-out (no hay propblock).
→ 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-pickerdefaultsize— ✅'sm'→'md'(decisión: consistencia).switch— ✅ STALE: ya NO declaraSwitchVariantni propvariant(soloSwitchSize). Variant-less correcto (color por intent+color). Sin acción.linkvariant'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 aControlVariant(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",coloraplica bajointent='neutral').bannerintent-only = componente de feedback. NO es deriva. - Transición: 17 consumen
var(--duration-fast)directo; los--{c}-transition-durationencadenan 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); los0.Xrestantes son otras opacidades (muted/overlay) ya cubiertas en dim. 2. NO finding nuevo.
PLAN DE REMEDIACIÓN POR LOTES
- Lote 0 — primitivos compartidos (enabler):
svg/:SvgX(+SvgPlus/SvgMinuspara inc/dec) — animables,pathLength,data-svg, hermanos de chevron/check/dash/dot.IconButton(eidos): componeButton,aria-labelrequerido, agnóstico de glifo, layout icon-only (data-icon-only). Sin morfo nuevo.Button: quitariconOnlydel API público (modo interno disparado por IconButton) +rounded='full'→var(--radius-full).- 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.
- soma (render
- 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/implementarcorner-shape; documentar la reglasvg/-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-offseten 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
surfacede la familia input a--color-surface-default;editable/tags-inputraised→default; arreglar el token fantasma detextarea(--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; quitarborder-color+box-shadowde 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,switchvariant colgante, renombrarlink'default', normalizar variants de code/code-block/kbd.
- Focus-ring: unificar a
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-labelobligatorio, glifo comochildren,iconOnlyinterno. Button:rounded='full'9999px→var(--radius-full). - Migrados (patrón: glifo→
children, label→aria-label, fueraiconOnly): 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→IconButtonmete 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,relativeganó aabsolute). 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) aplicapress-squeeze(transform: scale(0.96)), que reemplaza untransform: translateY(-50%)de centrado → salto al pulsar. Fix: centrar con la propiedadtranslate(compone con elscaleen vez de borrarlo); quitar overrides:active.
Pendiente del Lote 0:
- ② condicionales (NO icon-only puro):
clipboard-trigger(default texto "Copy") ydropdown-menu-trigger(trigger genérico) reenvíaniconOnlyopcional → no se migran de golpe. Decisión: render condicionalIconButton/Button, o se quedan en Button. Bloquea el cierre (quitariconOnlydel API público de Button). - corner-shape (dim 4 / Lote D): Button declara
shape+ emitedata-shapeperobutton.cssNO lo materializa (0 reglascorner-shape) → todo botón/IconButton es solo redondo-arco.SHAPE_ENGINE_RFC §6lo 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 |
|---|---|
(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 → DropdownMenucustomAnchor. Memoriaproject_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 grepfont-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 indicador1px→--radius-full. (button9999pxya estaba; cropper0 0 0 9999pxes el hack de máscara, NO tocar.) - Gap canónico trigger→panel (
1590684cfoundation+piloto +d229447acascade): elsideOffsetde floating-ui es un número (no acepta token), así que cada flotante hardcodeaba el suyo (0/4/6/8). Solución:@property --floating-gapque 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. Memoriaproject_floating_gap_canon_2026-06-22; doc en THEMING.md.
Pendiente (orden sugerido)
- ✅ 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-declarabapadding+flex+gap+overflow-x+scrollbar-gutteren el panel. Tras introducirse el[data-popover-viewport](padding + scroll + grid) eso causó: doble padding (content 14px + viewport 12px), elflex/gapaplicado al único hijo (el viewport) en vez de a las filas, los constraints> *mal dirigidos, y el grid item sinmin-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:0en shell/body;scrollbar-gutter: stableen 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/pyoverride + 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; tokenscontent-padding-{sm,md,lg}+content-gapBORRADOS del recipe (eran divergentes/huérfanos); var muerta--_color-picker-content-paddingeliminada. 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/pyes un outlier a corregir. - Canon de listas recalibrado 2026-06-23 (a petición del usuario tras el
fix):
--list-content-paddingsubió 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).commandes 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.
- HOMOGENEIZACIÓN (corrección de rumbo del usuario): el primer intento
preservó el padding PROPIO del color-picker (14px vía
- Faults de los selects (sección arriba): combobox (7 faults) → search-field → select → barrido langs cero-hardcodes.
- DIM 5A focus-ring (🔴 headline): unificar ~18
box-shadowfocus →outlineoutline-offset; cierra el a11y de forced-colors.
- 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. - 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-labely lo pisaba sobre elprops['aria-label']que soma ya resolvía desde el morfo (translationRef).propsesRecord<string, unknown>→ no satisface elaria-label: stringrequerido porIconButton, 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 encropperLangs) · soma time-picker day-period (añadióDAY_PERIODaTIME_PICKER_LANGSreusandocommon.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 poseeclipboardLangs), 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:
- 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.sveltey delegan (date-field ya NO usaaccent-elementfuerte + 600). Confirmado: tokens resuelven, marcador presente. - Flicker del borde al incrementar por teclado → arreglado: el input
transicionaba
border-color(+box-shadowvestigial, ya no hay focus-shadow tras 5A) y:has(segment:focus-visible)togglea el borde accent; al incrementar, el:focus-visibleparpadea (soma re-renderiza → blur/refocus) y la transición desvanecía el borde = flicker. Fix:transition: background …solo (border-color instantáneo; el anillo esoutline, 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:hoverque 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 === segmentoperomatches(':focus') === false), así que:focus-visibleno 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).
- Estado activo divergente + anillo + peso → ya lo resolvió la migración 5A:
existe una capa compartida
-
DIM 5B (nits de API) — cerrado con decisiones del usuario.
- switch: STALE —
switch/types.tsya NO declaraSwitchVariantni propvariant(soloSwitchSize). Correcto variant-less (el color va por intent+color). Sin acción. - code-block + kbd → aliasados a
ControlVariant: susurface|outline|ghostduplicaba literalmenteControlVariant→ doctrina components-README ("no unions locales con literales duplicados"). Cero cambio de valores/CSS/consumidores. - code → queda local (
plain|soft|outlineno 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).
- switch: STALE —
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)
- 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 estampandata-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>.
- ✅ popover-close + banner-close →
- DIM 1 — Lote B (estructural): 6
<select>nativos →<Select>(calendar month/year, range-calendar month/year, color-field/color-picker format). - Barrido de langs — cola (deferidos arriba): avatar-group conteo, date-range-picker soma views (entradas decade/range).
- DIM 5D-bis (color del label de field): decidir canon primary vs secondary
(field usa
content-primary; date/time/color-field + color-pickercontent-secondary). - 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-onlyde 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; memoriafeedback_button_consumer_pattern.