fix(eidos): component-coherence audit FASE 2 — focus-ring, fields, segments, langs, API nits

Bundles the uncommitted audit work on this branch (active-uix):

- DIM 5A: focus-ring unified to `outline` across the control/field set (kills
  ~18 box-shadow rings; closes forced-colors a11y). NOTE: spin-field +
  drawer-close orphan-token loose ends from this migration still trip the eidos
  contract test — to clean when that migration settles.
- DIM 5D: field/input backgrounds homogenized to surface-default; list-surface
  padding recalibrated 2/4/6/8/10; menu-dial internal vars privatized.
- DIM 5E: date/time/color-field + date-range-picker segments delegate the active
  state to the shared field-segment-state layer; the input transition is reduced
  to `background` only so the focus border no longer flickers on keyboard
  increment (border-color is now instant; the ring is an outline).
- Langs (cero-hardcodes): IconButton-cascade wrappers (carousel/combobox/
  tree-grid/table/search-field/banner) + onion-menu + cropper + time-picker
  day-period now route aria-labels through morfo langs instead of hardcoded
  English (the hardcode was overriding soma's already-resolved label). Fixes 2
  contract divergences (combobox Clear->Clear value, table toggle label).
- DIM 5B: code-block/kbd variants aliased to ControlVariant (no duplicate
  literals); image-picker default size sm->md.

Excludes words/palabras/chronos and the metrics/demos tracks (separate work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent c19a72b620
commit ebc742e73b

@ -4,7 +4,7 @@ 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-22" (al final). Hechos: action components (fab/onion-menu/button-group/split-button), font 1:1 en menús, 2A radius bypass, gap canónico trigger→panel. CONTINUAR mañana por el bug popover color-picker (task_5d1a10ec).
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
@ -265,30 +265,91 @@ El **valor** del foco es casi uniforme (`--color-focus-ring` = alias de
| `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)` +
`outline-offset: var(--focus-ring-offset)`; eliminar los 18 `--{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).
### 5D — Fondo de la familia field/input NO homogéneo (🔴)
Toda la familia default `variant='surface'` → **debería compartir fondo**. Casi
todos resuelven a `--color-surface-default` (= `--primitive-neutral-1`): field,
spin-field (number/css-field), date/time/color-field, search-field, password-field,
select-trigger, pin-input-cell. **Divergentes:**
| Componente | bg hoy | Debe ser |
|---|---|---|
| **editable** | `--color-surface-raised` (neutral-2) | `--color-surface-default` (o documentar si es deliberado) |
| **tags-input** | `--color-surface-raised` (neutral-2) | `--color-surface-default` |
| **textarea** | `var(--color-surface)` — **token FANTASMA** (no existe; solo `-default/-raised/-muted/-overlay`) → fondo roto/transparente | `var(--color-surface-default)` |
| **combobox** | hardcodea `var(--color-surface-default)` en CSS | token `--combobox-trigger-bg` (valor OK, patrón) |
→ Es lo que se ve: `editable`/`tags-input` con fondo más elevado, `textarea` sin
fondo. **Fix**: unificar el `surface` de la familia a `--color-surface-default`;
arreglar el fantasma de textarea; combobox vía token.
### 5E — Segmentos date/time/color: estado activo divergente + anillo + flicker (🔴)
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.
@ -347,16 +408,17 @@ familia, sin opt-out por campo. **Sin acción de código**: el estado actual ya
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 (🟡)
### 5B — Nits de API (🟡) — CERRADO 2026-06-24 (decisiones del usuario, ver hand-off)
- **`image-picker` default `size='sm'`** — todos los demás controles default `'md'`. Outlier.
- **`switch`** define el tipo `SwitchVariant` (= `SelectionVariant`) pero **no expone
prop `variant`** (sin `data-variant`) — tipo colgante o variant-less intencional
(verificar; checkbox/radio/toggle sí exponen variant `'solid'`).
- **`link` variant `'default'`** — valor no-semántico (el resto usa nombres como
`subtle`/`plain`/`soft`). Renombrar.
- **code / code-block / kbd**: subsets de variant ad-hoc (`'surface'|'outline'|'ghost'`,
`'plain'|'soft'|'outline'`) en vez de un alias-arquetipo. Familia "texto/superficie".
- **`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)
@ -472,10 +534,10 @@ paridad visual (hover/focus/disabled) + a11y (foco/teclado) en selects migrados.
| Componente | Faults |
|---|---|
| **Combobox** | (a) flecha abrir/cerrar es semántica → **emitir evento** · (b) popup **sin padding inferior** · (c) scroll → usar **`ScrollArea`** · (d) **bug**: clic en flecha con popup abierto lo cierra y **reabre** · (e) tipografía md=sm (**no 1:1** — combobox no entró en el sweep de fuente) · (f) popup **pegado al input** (solapa) · (g) **elevación** no "un paso más que el disparador" (escalera canonizada `default<raised<muted<overlay`) |
| **SearchField** | tipografía = la del input pero **menor peso** · scroll → **`ScrollArea`** · footer poco diferenciado → **subir un paso** la escala |
| **Select** | item seleccionado: **borde y resaltado muy pegados** → espacio coherente con tokens del tema |
| **Langs (REGLA transversal)** | **cero hardcodes** — las strings (aria-labels…) van en el **morfo** del componente vía langs. Incluye **corregir los `aria-label` hardcodeados** que dejó la cascada (tree-grid/table/search-field: "Toggle row"/etc. → langs) |
| ~~**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
@ -515,9 +577,43 @@ Sesión larga; varios cierres + el gap canónico. Para retomar:
doc en THEMING.md.
### Pendiente (orden sugerido)
1. **🔴 Bug popover color-picker** (task chip `task_5d1a10ec`) — EMPEZAR AQUÍ.
Paddings no canonizados + scroll horizontal + los sliders/area solapan el padding
del content (de ahí el scroll). Reportado por el usuario.
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`
@ -531,6 +627,104 @@ Sesión larga; varios cierres + el gap canónico. Para retomar:
---
## 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): closes dialog/drawer/popover/
banner/toast → `IconButton`+`SvgX`; calendar/range prev/next → `IconButton`+
`SvgChevron`; **code-block copy → componer `<Clipboard>`** (cierra también su
hardcode de langs). Borrar el chrome `--*-control-*`/`--*-close-*` redundante.
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`.

@ -6,23 +6,29 @@
* event-target role, no keyboard). The consumer wires `onclick` to
* their own visibility state — Banner does not own the dismissal.
*
* Defaults `aria-label="Dismiss"` so consumers don't have to remember,
* but the consumer can override.
* Defaults to a localized "Dismiss" `aria-label` (via langs) so consumers
* don't have to remember, but the consumer can override.
*/
import { ActiveEidos } from '$uix/eidos';
import type { BannerCloseProps } from './types';
let {
'aria-label': ariaLabel = 'Dismiss',
'aria-label': ariaLabel,
class: className,
children,
...restProps
}: BannerCloseProps = $props();
const eidos = ActiveEidos.require();
// Accessible name routes through langs — explicit prop wins, else the
// localized default. No hardcoded English.
const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts('#?common.buttons.dismiss|Dismiss'));
</script>
<button
type="button"
{...restProps}
aria-label={ariaLabel}
aria-label={resolvedLabel}
class={className}
data-banner-close=""
>

@ -85,5 +85,17 @@
/* Grid item placement — Box used as a grid child declares its slot. */
grid-column: var(--box-grid-column, revert-layer);
grid-row: var(--box-grid-row, revert-layer);
}
/*
* `grid-area` is a shorthand that also resets grid-row / grid-column, so it
* cannot be emitted unconditionally next to them — `--box-grid-area` defaults
* to `revert-layer` (declared on :root), which would make the shorthand fire
* on every Box and wipe an explicit `gridRow` / `gridColumn` span. Gate it
* behind `data-box-area` (set by box.svelte only when the `gridArea` prop is
* provided). The extra attribute also lifts specificity so an intentional
* `gridArea` wins over the longhands above.
*/
[data-box][data-box-area] {
grid-area: var(--box-grid-area, revert-layer);
}

@ -74,6 +74,12 @@
const eidos = ActiveEidos.require();
// `gridArea` is a shorthand that also writes grid-row / grid-column.
// Resolved once so it both feeds the CSS var and gates the
// `data-box-area` guard attr (see box.css) — the guard keeps the
// shorthand from clobbering an explicit `gridRow` / `gridColumn`.
const resolvedGridArea = $derived(formatLayoutRaw(eidos.resolve(gridArea)));
const resolvedStyle = $derived.by(() => {
const decls: string[] = [];
@ -128,12 +134,18 @@
// Grid item placement (on the child, not the container).
pushStyleVar(decls, '--box-grid-column', formatLayoutRaw(eidos.resolve(gridColumn)));
pushStyleVar(decls, '--box-grid-row', formatLayoutRaw(eidos.resolve(gridRow)));
pushStyleVar(decls, '--box-grid-area', formatLayoutRaw(eidos.resolve(gridArea)));
pushStyleVar(decls, '--box-grid-area', resolvedGridArea);
return composeStyle(decls, style);
});
</script>
<div {...restProps} data-box="" class={className} style={resolvedStyle}>
<div
{...restProps}
data-box=""
data-box-area={resolvedGridArea ? '' : undefined}
class={className}
style={resolvedStyle}
>
{@render children?.()}
</div>

@ -59,7 +59,8 @@
}
[data-breadcrumb-link]:focus-visible {
box-shadow: var(--breadcrumb-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-breadcrumb-link][data-current] {
@ -104,7 +105,8 @@
}
[data-breadcrumb-ellipsis][data-interactive]:focus-visible {
box-shadow: var(--breadcrumb-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
@media (prefers-reduced-motion: reduce) {

@ -186,8 +186,8 @@
[data-calendar-month-select]:focus-visible,
[data-calendar-year-select]:focus-visible,
[data-calendar-day]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-calendar-prev-button][data-disabled],

@ -3,6 +3,7 @@
* Eidos `Carousel.NextTrigger` — symmetric mirror of `PrevTrigger`.
* Right-pointing chevron, identical visual API, rendered as `<IconButton>`.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Carousel from '$soma/components/carousel';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgChevron } from '$uix/eidos/components/svg';
@ -17,14 +18,18 @@
intent,
color,
icon: customIcon,
'aria-label': ariaLabel = 'Next slide',
'aria-label': ariaLabel,
...rest
}: CarouselNextTriggerProps = $props();
const eidos = ActiveEidos.require();
const carousel = getCarouselEidosContext();
const resolvedSize = $derived(size ?? carousel?.size ?? 'md');
// In vertical orientation `next` means "go down" — chevron mirrors.
const chevronDir = $derived(carousel?.orientation === 'vertical' ? 'down' : 'right');
// Accessible name routes through langs (morfo's translationRef) — the
// explicit prop wins, else the localized default. No hardcoded English.
const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts('#?components.carousel.next|Next slide'));
</script>
<Carousel.NextTrigger {...rest}>
@ -32,7 +37,7 @@
{#if outerChild}
{@render outerChild({ props })}
{:else}
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={ariaLabel}>
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={resolvedLabel}>
{#if customIcon}{@render customIcon()}{:else}<SvgChevron direction={chevronDir} />{/if}
</IconButton>
{/if}

@ -6,6 +6,7 @@
* Consumers override visual props inline; a `child` snippet bypasses
* IconButton entirely. Size cascades from the Carousel root.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Carousel from '$soma/components/carousel';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgChevron } from '$uix/eidos/components/svg';
@ -20,14 +21,18 @@
intent,
color,
icon: customIcon,
'aria-label': ariaLabel = 'Previous slide',
'aria-label': ariaLabel,
...rest
}: CarouselPrevTriggerProps = $props();
const eidos = ActiveEidos.require();
const carousel = getCarouselEidosContext();
const resolvedSize = $derived(size ?? carousel?.size ?? 'md');
// In vertical orientation `prev` means "go up" — chevron mirrors.
const chevronDir = $derived(carousel?.orientation === 'vertical' ? 'up' : 'left');
// Accessible name routes through langs (morfo's translationRef) — the
// explicit prop wins, else the localized default. No hardcoded English.
const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts('#?components.carousel.prev|Previous slide'));
</script>
<Carousel.PrevTrigger {...rest}>
@ -35,7 +40,7 @@
{#if outerChild}
{@render outerChild({ props })}
{:else}
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={ariaLabel}>
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={resolvedLabel}>
{#if customIcon}{@render customIcon()}{:else}<SvgChevron direction={chevronDir} />{/if}
</IconButton>
{/if}

@ -73,7 +73,8 @@
}
[data-checkbox]:focus-visible {
box-shadow: var(--checkbox-focus-ring);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
/* ── States: checked / indeterminate ─────────────────────────────────────

@ -98,8 +98,8 @@
}
[data-code-block-copy]:focus-visible {
outline: var(--border-width-medium) solid var(--color-primary-solid);
outline-offset: 2px;
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-code-block-copy][data-copied] {

@ -1,12 +1,12 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
import type { ControlVariant, ResponsiveProp } from '$uix/eidos/lib/types';
/** Discrete size scale for the block's code font. */
export type CodeBlockSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Visual chrome. */
export type CodeBlockVariant = 'surface' | 'outline' | 'ghost';
/** Visual chrome — the canonical control variant family (`surface|outline|ghost`). */
export type CodeBlockVariant = ControlVariant;
export type CodeBlockProps = Omit<HTMLAttributes<HTMLDivElement>, 'style' | 'children' | 'title'> & {
/** Title shown in the header (typically a filename or label). */

@ -119,10 +119,13 @@
border-radius: var(--color-field-radius);
background: var(--color-field-bg);
color: inherit;
transition:
background var(--color-field-transition-duration) var(--color-field-transition-ease),
border-color var(--color-field-transition-duration) var(--color-field-transition-ease),
box-shadow var(--color-field-transition-duration) var(--color-field-transition-ease);
/* Only `background` transitions. `border-color` must be INSTANT: the focus
border toggles via `:has(segment:focus-visible)`, and a keyboard increment
briefly blurs/refocuses the segment — a transitioned border-color would
fade out/in on every step (visible flicker). The focus ring is an
`outline` (instant). `box-shadow` is no longer used here (post focus-ring
migration) — dropped from the transition. */
transition: background var(--color-field-transition-duration) var(--color-field-transition-ease);
}
[data-color-field][data-variant='outline'] [data-color-field-input] {
@ -137,7 +140,9 @@
[data-color-field-input]:has([data-color-field-segment]:focus-visible) {
border-color: var(--_color-field-accent-border);
box-shadow: var(--color-field-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-color-field][data-invalid] [data-color-field-input],

@ -19,7 +19,6 @@
*/
[data-color-picker] {
--_color-picker-content-padding: var(--color-picker-content-padding-md);
--_color-picker-content-width: var(--color-picker-content-width-md);
--_color-picker-trigger-height: var(--color-picker-trigger-height-md);
--_color-picker-trigger-px: var(--color-picker-trigger-px-md);
@ -44,7 +43,6 @@
}
[data-color-picker][data-size='sm'] {
--_color-picker-content-padding: var(--color-picker-content-padding-sm);
--_color-picker-content-width: var(--color-picker-content-width-sm);
--_color-picker-trigger-height: var(--color-picker-trigger-height-sm);
--_color-picker-trigger-px: var(--color-picker-trigger-px-sm);
@ -54,7 +52,6 @@
}
[data-color-picker][data-size='lg'] {
--_color-picker-content-padding: var(--color-picker-content-padding-lg);
--_color-picker-content-width: var(--color-picker-content-width-lg);
--_color-picker-trigger-height: var(--color-picker-trigger-height-lg);
--_color-picker-trigger-px: var(--color-picker-trigger-px-lg);
@ -72,10 +69,14 @@
[data-color-picker][data-color='loss'] { --_color-picker-accent: var(--color-loss-border); }
[data-color-picker-label] {
color: var(--color-content-secondary);
color: var(--color-content-primary);
font: inherit;
font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
/* One typographic step below the trigger control font (md − sm = 2px) so the
label SCALES with the picker size — matching the field / date / time / color
field label canon. The label is a sibling of the trigger (not a descendant),
so it reads the trigger font token directly instead of `1em`. Weight stays
inherited (regular), like the other field labels — not a bespoke medium. */
font-size: calc(var(--_color-picker-trigger-font-size) - (var(--font-size-md) - var(--font-size-sm)));
}
[data-color-picker-control] {
@ -102,30 +103,32 @@
--_popover-content-min-width-override: var(--color-picker-content-width-md);
--_popover-content-max-width-override: calc(100vw - var(--space-4));
display: flex;
flex-direction: column;
gap: var(--color-picker-content-gap);
padding: var(--color-picker-content-padding-md);
/* Padding · scroll · row layout all belong to the popover's inner
[data-popover-viewport] (padding + overflow) and the [data-picker-shell] /
[data-picker-body] (flex column + inter-row gap). We do NOT re-declare them
here — doing so double-padded the picker (content padding + viewport
padding) and forced horizontal scroll. Critically, we also do NOT override
the viewport's padding: the panel padding is the popover's canonical,
size-aware `--popover-padding-*` — the SAME padding every other floating
panel (date / time / range pickers, link-preview, plain popovers) already
inherits. The color picker is a popover; its panel must look like one. */
}
/* The picker shell is the single grid item inside the popover viewport; its
body holds the rows. Let both shrink below their min-content so a wide
intrinsic row (segmented channel input, swatch strip) can never force the
viewport into horizontal scroll. */
[data-popover-content][data-color-picker-content] [data-picker-shell],
[data-popover-content][data-color-picker-content] [data-picker-body] {
min-inline-size: 0;
max-inline-size: 100%;
box-sizing: border-box;
overflow-x: hidden;
/* Reserve space for the scrollbar so the content (incl. area thumb) does
NOT shift laterally when the scrollbar appears/disappears (e.g. when
content exceeds the popover's max-block-size — which happens easily
once the picker has alpha slider + swatches + footer). Without this,
the area visibly jumps during drag as state updates and focus rings
cause the browser to toggle scroll visibility. */
scrollbar-gutter: stable;
}
/* All direct rows of the picker content (area, sliders, channel input,
swatches, footer, etc.) must shrink to fit the popover width — never
force horizontal scroll. */
[data-popover-content][data-color-picker-content] > * {
min-inline-size: 0;
max-inline-size: 100%;
box-sizing: border-box;
/* The viewport is the scroll container. Reserve the scrollbar gutter so a
vertical scrollbar appearing/disappearing mid-drag (state updates + focus
rings toggle its visibility once the picker is tall enough to scroll) does
NOT shift the area laterally. */
[data-popover-content][data-color-picker-content] [data-popover-viewport] {
scrollbar-gutter: stable;
}
/* ── Trigger ─────────────────────────────────────────────────────────────── */
@ -158,9 +161,10 @@
}
[data-color-picker-trigger]:focus-visible {
outline: none;
border-color: var(--_color-picker-accent);
box-shadow: var(--color-picker-trigger-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-color-picker-trigger][data-disabled] {
@ -594,8 +598,11 @@
/* ── ChannelInput integration with ColorField inside the popover ────────── */
[data-popover-content][data-color-picker-content] [data-color-field-input] {
background: var(--color-surface-overlay);
border-color: transparent;
/* Background + border stay the field's own (surface-default + border-default)
so the value field reads as an editable input that CONTRASTS with the popover
overlay surface. The previous `surface-overlay` + transparent border matched
the popover exactly, making the field look transparent (it vanished into the
panel). Only layout (fit + wrap) is popover-specific here. */
inline-size: 100%;
max-inline-size: 100%;
min-inline-size: 0;

@ -11,6 +11,7 @@
* Composition: callers can pass a custom glyph via `children`, or a
* `child` snippet (full bypass of IconButton).
*/
import { ActiveEidos } from '$uix/eidos';
import * as Combobox from '$soma/components/combobox';
import { IconButton } from '$uix/eidos/components/icon-button';
import type { ComboboxClearButtonProps } from './types';
@ -21,9 +22,14 @@
variant = 'ghost',
size = 'xs',
rounded,
'aria-label': ariaLabel = 'Clear',
'aria-label': ariaLabel,
...rest
}: ComboboxClearButtonProps = $props();
const eidos = ActiveEidos.require();
// Accessible name routes through langs (morfo's translationRef) — the
// explicit prop wins, else the localized default. No hardcoded English.
const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts('#?components.combobox.clear|Clear value'));
</script>
<Combobox.ClearButton {...rest}>
@ -38,7 +44,7 @@
{rounded}
intent="neutral"
color="neutral"
aria-label={ariaLabel ?? 'Clear'}
aria-label={resolvedLabel}
>
{#if customGlyph}
{@render customGlyph()}

@ -149,9 +149,9 @@
[data-combobox-control]:has([data-combobox-trigger]:focus-visible),
[data-combobox-control]:has([data-state='open']) {
border-color: var(--_combobox-accent-border);
box-shadow:
0 0 0 var(--focus-ring-offset) var(--color-surface-default),
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-combobox-control]:has([data-disabled]),
@ -220,9 +220,8 @@
}
[data-combobox-trigger]:focus-visible {
box-shadow:
0 0 0 var(--focus-ring-offset) var(--color-surface-default),
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-combobox-trigger][data-disabled],

@ -183,6 +183,6 @@
}
.eidos-cropper-zoom-btn:focus-visible {
outline: var(--focus-ring-width) solid var(--color-focus-ring);
outline-offset: 1px;
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}

@ -26,6 +26,12 @@
const eidos = ActiveEidos.require();
// Zoom-control accessible names route through langs — no hardcoded English.
const zoomOutLabel = $derived(eidos.langs.ts('#?components.cropper.zoom-out|Zoom out'));
const zoomLabel = $derived(eidos.langs.ts('#?components.cropper.zoom|Zoom'));
const zoomInLabel = $derived(eidos.langs.ts('#?components.cropper.zoom-in|Zoom in'));
const resetZoomLabel = $derived(eidos.langs.ts('#?components.cropper.reset-zoom|Reset zoom'));
let providerComp = $state<{ cropImage: () => Promise<CropResult | null> } | null>(null);
/** Produce the cropped image now (also fires `onCropComplete`). Via `bind:this`. */
@ -92,7 +98,7 @@
<button
type="button"
class="eidos-cropper-zoom-btn"
aria-label="Zoom out"
aria-label={zoomOutLabel}
onclick={() => zoomTo(scale / 1.2)}
>
−
@ -103,7 +109,7 @@
max={maxScale}
step={0.01}
size="sm"
aria-label="Zoom"
aria-label={zoomLabel}
onValueChange={(v) => zoomTo(v[0])}
>
<Slider.Range />
@ -112,7 +118,7 @@
<button
type="button"
class="eidos-cropper-zoom-btn"
aria-label="Zoom in"
aria-label={zoomInLabel}
onclick={() => zoomTo(scale * 1.2)}
>
+
@ -120,7 +126,7 @@
<button
type="button"
class="eidos-cropper-zoom-btn"
aria-label="Reset zoom"
aria-label={resetZoomLabel}
onclick={resetZoom}
>
⟲

@ -122,10 +122,13 @@
border-radius: var(--date-field-radius);
background: var(--date-field-bg);
color: inherit;
transition:
background var(--date-field-transition-duration) var(--date-field-transition-ease),
border-color var(--date-field-transition-duration) var(--date-field-transition-ease),
box-shadow var(--date-field-transition-duration) var(--date-field-transition-ease);
/* Only `background` transitions. `border-color` must be INSTANT: the focus
border toggles via `:has(segment:focus-visible)`, and a keyboard increment
briefly blurs/refocuses the segment — a transitioned border-color would
fade out/in on every step (visible flicker). The focus ring is an
`outline` (instant). `box-shadow` is no longer used here (post focus-ring
migration) — dropped from the transition. */
transition: background var(--date-field-transition-duration) var(--date-field-transition-ease);
}
[data-date-field][data-variant='outline'] [data-date-field-input] {
@ -140,7 +143,9 @@
[data-date-field-input]:has([data-date-field-segment]:focus-visible) {
border-color: var(--_date-field-accent-border);
box-shadow: var(--date-field-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-date-field][data-invalid] [data-date-field-input],

@ -182,10 +182,11 @@
border-radius: var(--date-field-radius);
background: var(--date-field-bg);
color: inherit;
transition:
background var(--date-field-transition-duration) var(--date-field-transition-ease),
border-color var(--date-field-transition-duration) var(--date-field-transition-ease),
box-shadow var(--date-field-transition-duration) var(--date-field-transition-ease);
/* Only `background` transitions — `border-color` must be INSTANT (the focus
border toggles via `:has(segment:focus-visible)` and a keyboard increment
blurs/refocuses the segment; a transitioned border would flicker). Focus
ring is an `outline` (instant). `box-shadow` is unused here post-migration. */
transition: background var(--date-field-transition-duration) var(--date-field-transition-ease);
}
[data-date-range-picker][data-variant='outline'] [data-date-range-field-input] {
@ -200,7 +201,8 @@
[data-date-range-field-input]:has([data-date-field-segment]:focus-visible) {
border-color: var(--_date-field-accent-border);
box-shadow: var(--date-field-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
/* ── Group treatment: single input chrome around start + end ────────────────
@ -221,15 +223,17 @@
border-radius: var(--date-field-radius);
background: var(--date-field-bg);
color: inherit;
transition:
background var(--date-field-transition-duration) var(--date-field-transition-ease),
border-color var(--date-field-transition-duration) var(--date-field-transition-ease),
box-shadow var(--date-field-transition-duration) var(--date-field-transition-ease);
/* Only `background` transitions — `border-color` must be INSTANT (the focus
border toggles via `:has(segment:focus-visible)` and a keyboard increment
blurs/refocuses the segment; a transitioned border would flicker). Focus
ring is an `outline` (instant). `box-shadow` is unused here post-migration. */
transition: background var(--date-field-transition-duration) var(--date-field-transition-ease);
}
[data-date-range-field-group]:has([data-date-field-segment]:focus-visible) {
border-color: var(--_date-field-accent-border);
box-shadow: var(--date-field-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-date-range-picker][data-variant='outline'] [data-date-range-field-group] {
@ -361,9 +365,10 @@
}
[data-date-range-picker-trigger]:focus-visible {
outline: none;
border-color: var(--_date-field-accent-border);
box-shadow: var(--date-field-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-date-range-picker-trigger][data-disabled],

@ -1,34 +1,46 @@
<script lang="ts">
/**
* Eidos `Drawer.Close` — adds the auto-X icon when no children are
* provided (matches the air baseline) and emits `data-icon-only` so the
* recipe can size the button as a square. The icon is inline SVG (no
* external Icon dependency) to keep the wrapper self-contained.
* Eidos `Drawer.Close` — composes the canonical button primitives via soma's
* `child` snippet (no bespoke close chrome — it inherits Button's variant /
* focus-ring / disabled):
*
* Consumers that pass children (e.g. `<Drawer.Close>Cancel</Drawer.Close>`)
* get a regular text button.
* - no children → icon close: an `<IconButton>` with the ✕ glyph and a
* localized "Close" `aria-label`.
* - children → text close: a `<Button>` with the label inline (e.g. a
* "Cancel" action in a Footer).
*
* A `child` snippet bypasses both primitives.
*/
import { ActiveEidos } from '$uix/eidos';
import * as Drawer from '$soma/components/drawer';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgX } from '$uix/eidos/components/svg';
import type { DrawerCloseProps } from './types';
let { children, ...rest }: DrawerCloseProps = $props();
let {
'aria-label': ariaLabel,
child: outerChild,
children,
...rest
}: DrawerCloseProps = $props();
const eidos = ActiveEidos.require();
const closeLabel = $derived(ariaLabel ?? eidos.langs.ts('#?common.buttons.close|Close'));
</script>
<Drawer.Close {...rest} data-icon-only={!children ? '' : undefined}>
{#if children}
{@render children()}
{:else}
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
{/if}
<Drawer.Close {...rest}>
{#snippet child({ props })}
{#if outerChild}
{@render outerChild({ props })}
{:else if children}
<Button {...props} aria-label={ariaLabel ?? undefined}>
{@render children()}
</Button>
{:else}
<IconButton {...props} aria-label={closeLabel}>
<SvgX />
</IconButton>
{/if}
{/snippet}
</Drawer.Close>

@ -442,47 +442,6 @@ body:has([data-drawer-content][data-dragging]) {
}
/* ── Close button ───────────────────────────────────────────────────────── */
[data-drawer-close] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-inline-size: var(--drawer-close-size);
block-size: var(--drawer-close-size);
padding-inline: var(--drawer-close-px);
border: var(--drawer-close-border-width) solid var(--drawer-close-border);
border-radius: var(--drawer-close-radius);
background: var(--drawer-close-bg);
color: var(--drawer-close-color);
cursor: pointer;
transition:
background var(--drawer-close-transition-duration) var(--drawer-close-transition-ease),
color var(--drawer-close-transition-duration) var(--drawer-close-transition-ease),
border-color var(--drawer-close-transition-duration) var(--drawer-close-transition-ease),
box-shadow var(--drawer-close-transition-duration) var(--drawer-close-transition-ease);
}
[data-drawer-close][data-icon-only] {
inline-size: var(--drawer-close-size);
padding-inline: 0;
}
[data-drawer-close]:hover {
background: var(--drawer-close-bg-hover);
color: var(--drawer-close-color-hover);
}
[data-drawer-close]:focus-visible {
outline: none;
box-shadow:
0 0 0 var(--drawer-close-focus-ring-offset) var(--drawer-content-bg),
0 0 0 calc(var(--drawer-close-focus-ring-offset) + var(--drawer-close-focus-ring-width))
var(--drawer-close-focus-ring-color);
}
[data-drawer-close]:disabled,
[data-drawer-close][data-disabled] {
cursor: default;
opacity: var(--drawer-close-disabled-opacity);
}
/* Chrome (size · border · bg · hover · focus-ring · disabled) comes from the
composed `<IconButton>` / `<Button>` in drawer-close.svelte — no bespoke close
chrome here. */

@ -93,7 +93,9 @@
inline-size: 100%;
border-color: var(--_editable-accent-border);
background: var(--editable-control-bg);
box-shadow: var(--editable-control-shadow), var(--editable-focus-shadow);
box-shadow: var(--editable-control-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-editable][data-variant='outline'] [data-editable-input] {
@ -128,7 +130,9 @@
[data-editable-preview]:focus-visible,
[data-editable-input]:focus-visible {
border-color: var(--_editable-accent-border);
box-shadow: var(--editable-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
/* Invalid state: mirror field.css risk-border treatment on Input + Control. */
@ -196,7 +200,8 @@
[data-editable-edit-trigger]:focus-visible,
[data-editable-submit-trigger]:focus-visible,
[data-editable-cancel-trigger]:focus-visible {
box-shadow: var(--editable-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-editable-preview][hidden],

@ -45,8 +45,8 @@
}
[data-field-control-trigger]:focus-visible {
outline: var(--border-width-medium) solid var(--color-focus-ring);
outline-offset: -1px;
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
color: var(--color-content-primary);
}

@ -206,7 +206,11 @@
[data-field-control]:focus-within {
border-color: var(--field-control-border-focus);
box-shadow: var(--field-control-focus-shadow);
/* Focus ring as outline (not box-shadow) so it survives forced-colors / HCM.
No offset: the ring sits flush against the control box — no gap (the old
box-shadow filled the gap with surface, an outline gap would show through). */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-field][data-invalid] [data-field-control],

@ -10,7 +10,7 @@
let {
size = 'md',
variant = 'outline',
variant = 'surface',
orientation = 'vertical',
color = 'primary',
children: bodyContent,

@ -27,7 +27,7 @@ export type FieldColor = ColorRole;
export type FieldProps = ProviderProps & {
/** Visual size. Accepts a responsive map resolved by ActiveEidos. @default 'md' */
size?: ResponsiveProp<FieldSize>;
/** Visual variant for the control shell. @default 'outline' */
/** Visual variant for the control shell. @default 'surface' */
variant?: FieldVariant;
/** Label/control layout. @default 'vertical' */
orientation?: FieldOrientation;

@ -75,7 +75,8 @@
[data-file-upload-trigger]:focus-visible,
[data-file-upload-item-remove]:focus-visible,
[data-file-upload-clear-trigger]:focus-visible {
box-shadow: var(--file-upload-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-file-upload-trigger],

@ -68,8 +68,8 @@
}
[data-image-adjustments-reset]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-focus-ring);
outline-offset: 2px;
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
border-radius: var(--radius-xs);
}

@ -87,8 +87,8 @@
[data-image-picker-rotate]:focus-visible,
[data-image-picker-remove]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-focus-ring);
outline-offset: 2px;
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-image-picker-rotate][disabled],

@ -26,7 +26,7 @@
disabled = false,
accept = 'image/*',
maxSize,
size = 'sm',
size = 'md',
showAdjustments = true,
...headlessProps
}: ImagePickerProps = $props();

@ -18,7 +18,7 @@ export type ImagePickerProps = ProviderProps & {
accept?: string;
/** Max file size in bytes → FileUpload. */
maxSize?: number;
/** Scale of the embedded adjustments panel. @default 'sm' */
/** Scale of the embedded adjustments panel. @default 'md' */
size?: ResponsiveProp<ImagePickerSize>;
/** Render the ImageAdjustments panel below the preview. @default true */
showAdjustments?: boolean;

@ -1,12 +1,12 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
import type { ControlVariant, ResponsiveProp } from '$uix/eidos/lib/types';
/** Discrete size scale. */
export type KbdSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Visual treatment. */
export type KbdVariant = 'surface' | 'outline' | 'ghost';
/** Visual treatment — the canonical control variant family (`surface|outline|ghost`). */
export type KbdVariant = ControlVariant;
export type KbdProps = Omit<HTMLAttributes<HTMLElement>, 'style' | 'children'> & {
/** Visual treatment. @default 'surface' */

@ -98,8 +98,8 @@
}
[data-link]:focus-visible {
outline: var(--border-width-medium) solid var(--color-primary-solid);
outline-offset: 2px;
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-link][data-disabled] {

@ -35,7 +35,7 @@
<Tooltip.Trigger>
{#snippet child({ props }: { props: Record<string, unknown> })}
<!-- The trigger IS the positioned action wrapper (no extra element). -->
<div class="menu-dial-action" style="--menu-dial-i: {index};" {...props}>
<div class="menu-dial-action" style="--_menu-dial-i: {index};" {...props}>
<Fab
data-menu-dial-action=""
data-disabled={disabled ? '' : undefined}

@ -17,14 +17,14 @@
[data-menu-dial] {
display: inline-flex;
align-items: center;
gap: var(--menu-dial-gap, var(--space-3));
gap: var(--_menu-dial-gap, var(--space-3));
/* Floating offset from the viewport edges (mirrors the FAB). */
--menu-dial-offset: var(--space-5);
/* Per-action stagger step. */
--menu-dial-stagger-step: 28ms;
--_menu-dial-offset: var(--space-5);
/* Per-action stagger step (private — internal animation timing). */
--_menu-dial-stagger-step: 28ms;
/* Scrim behind the open fan. */
--menu-dial-backdrop-bg: color-mix(in oklab, var(--primitive-neutral-12) 40%, transparent);
--_menu-dial-backdrop-bg: color-mix(in oklab, var(--primitive-neutral-12) 40%, transparent);
}
/* ── Fan layout per direction ────────────────────────────────────────── */
@ -49,7 +49,7 @@
[data-menu-dial-list] {
display: inline-flex;
align-items: center;
gap: var(--menu-dial-gap, var(--space-3));
gap: var(--_menu-dial-gap, var(--space-3));
}
/* ── Floating placement (fixed to the viewport) — the 9 zones ──────────── */
@ -68,22 +68,22 @@
}
/* Edges — centred on the free axis. Centre — fully centred. */
[data-menu-dial][data-placement='top-center'] {
inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-block-start: calc(var(--_menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-inline-start: 50%;
translate: -50% 0;
}
[data-menu-dial][data-placement='bottom-center'] {
inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-block-end: calc(var(--_menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-start: 50%;
translate: -50% 0;
}
[data-menu-dial][data-placement='left-center'] {
inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
inset-inline-start: calc(var(--_menu-dial-offset) + env(safe-area-inset-left, 0px));
inset-block-start: 50%;
translate: 0 -50%;
}
[data-menu-dial][data-placement='right-center'] {
inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
inset-inline-end: calc(var(--_menu-dial-offset) + env(safe-area-inset-right, 0px));
inset-block-start: 50%;
translate: 0 -50%;
}
@ -93,20 +93,20 @@
translate: -50% -50%;
}
[data-menu-dial][data-placement='bottom-end'] {
inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
inset-block-end: calc(var(--_menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-end: calc(var(--_menu-dial-offset) + env(safe-area-inset-right, 0px));
}
[data-menu-dial][data-placement='bottom-start'] {
inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
inset-block-end: calc(var(--_menu-dial-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-start: calc(var(--_menu-dial-offset) + env(safe-area-inset-left, 0px));
}
[data-menu-dial][data-placement='top-end'] {
inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
inset-block-start: calc(var(--_menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-inline-end: calc(var(--_menu-dial-offset) + env(safe-area-inset-right, 0px));
}
[data-menu-dial][data-placement='top-start'] {
inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
inset-block-start: calc(var(--_menu-dial-offset) + env(safe-area-inset-top, 0px));
inset-inline-start: calc(var(--_menu-dial-offset) + env(safe-area-inset-left, 0px));
}
/* ── Actions: open / close + stagger ─────────────────────────────────── */
@ -143,7 +143,7 @@
/* Stagger — each action offset from the trigger by its index. */
[data-menu-dial][data-stagger] [data-menu-dial-list][data-state='open'] .menu-dial-action {
transition-delay: calc(var(--menu-dial-i, 0) * var(--menu-dial-stagger-step));
transition-delay: calc(var(--_menu-dial-i, 0) * var(--_menu-dial-stagger-step));
}
/* Labels — the action's name is a composed <Tooltip> (`menu-dial-action.svelte`),
@ -152,7 +152,7 @@
/* ── Arc layout (single arc, opening per placement) ──────────────────────
The list overlays the trigger; each action is absolutely placed on a circle
of radius `--menu-dial-radius` at an angle spread across the arc. Polar frame:
of radius `--_menu-dial-radius` at an angle spread across the arc. Polar frame:
0° = up, clockwise — mirrors the OnionMenu geometry (`onionArc` / `polar`). */
/* Containing block for the absolute list (floating placements are already `fixed`). */
@ -193,70 +193,70 @@
/* Arc opening per placement — span + start (deg) into the free space.
Corners → quarter (90°); static → a half (180°) oriented by `direction`. */
[data-menu-dial][data-layout='arc'][data-placement='bottom-end'] {
--menu-dial-arc-start: 270deg; /* up-left */
--menu-dial-arc-span: 90deg;
--_menu-dial-arc-start: 270deg; /* up-left */
--_menu-dial-arc-span: 90deg;
}
[data-menu-dial][data-layout='arc'][data-placement='bottom-start'] {
--menu-dial-arc-start: 0deg; /* up-right */
--menu-dial-arc-span: 90deg;
--_menu-dial-arc-start: 0deg; /* up-right */
--_menu-dial-arc-span: 90deg;
}
[data-menu-dial][data-layout='arc'][data-placement='top-end'] {
--menu-dial-arc-start: 180deg; /* down-left */
--menu-dial-arc-span: 90deg;
--_menu-dial-arc-start: 180deg; /* down-left */
--_menu-dial-arc-span: 90deg;
}
[data-menu-dial][data-layout='arc'][data-placement='top-start'] {
--menu-dial-arc-start: 90deg; /* down-right */
--menu-dial-arc-span: 90deg;
--_menu-dial-arc-start: 90deg; /* down-right */
--_menu-dial-arc-span: 90deg;
}
/* edges → half (180°) opening away from the anchored edge. */
[data-menu-dial][data-layout='arc'][data-placement='top-center'] {
--menu-dial-arc-start: 90deg; /* opens down */
--menu-dial-arc-span: 180deg;
--_menu-dial-arc-start: 90deg; /* opens down */
--_menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='bottom-center'] {
--menu-dial-arc-start: 270deg; /* opens up */
--menu-dial-arc-span: 180deg;
--_menu-dial-arc-start: 270deg; /* opens up */
--_menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='left-center'] {
--menu-dial-arc-start: 0deg; /* opens right */
--menu-dial-arc-span: 180deg;
--_menu-dial-arc-start: 0deg; /* opens right */
--_menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='right-center'] {
--menu-dial-arc-start: 180deg; /* opens left */
--menu-dial-arc-span: 180deg;
--_menu-dial-arc-start: 180deg; /* opens left */
--_menu-dial-arc-span: 180deg;
}
/* centre → full ring (360°): the actions distribute around the whole circle. */
[data-menu-dial][data-layout='arc'][data-placement='center'] {
--menu-dial-arc-start: 0deg;
--menu-dial-arc-span: 360deg;
--_menu-dial-arc-start: 0deg;
--_menu-dial-arc-span: 360deg;
}
[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='up'] {
--menu-dial-arc-start: 270deg; /* opens up */
--menu-dial-arc-span: 180deg;
--_menu-dial-arc-start: 270deg; /* opens up */
--_menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='down'] {
--menu-dial-arc-start: 90deg; /* opens down */
--menu-dial-arc-span: 180deg;
--_menu-dial-arc-start: 90deg; /* opens down */
--_menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='left'] {
--menu-dial-arc-start: 180deg; /* opens left */
--menu-dial-arc-span: 180deg;
--_menu-dial-arc-start: 180deg; /* opens left */
--_menu-dial-arc-span: 180deg;
}
[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='right'] {
--menu-dial-arc-start: 0deg; /* opens right */
--menu-dial-arc-span: 180deg;
--_menu-dial-arc-start: 0deg; /* opens right */
--_menu-dial-arc-span: 180deg;
}
/* Radius: clear the trigger, AND spread far enough that adjacent actions
(chord = 2R·sin(span/2N)) don't overlap — whichever is larger. */
[data-menu-dial][data-layout='arc'] {
--_dial-n: max(var(--menu-dial-count, 1), 1);
--_dial-n: max(var(--_menu-dial-count, 1), 1);
--_dial-r-clear: calc((var(--_dial-trigger-size) + var(--_dial-action-size)) / 2 + var(--space-3));
--_dial-r-spread: calc(
(var(--_dial-action-size) + var(--space-2)) /
(2 * sin(var(--menu-dial-arc-span, 90deg) / (2 * max(var(--_dial-n), 2))))
(2 * sin(var(--_menu-dial-arc-span, 90deg) / (2 * max(var(--_dial-n), 2))))
);
--menu-dial-radius: max(var(--_dial-r-clear), var(--_dial-r-spread));
--_menu-dial-radius: max(var(--_dial-r-clear), var(--_dial-r-spread));
}
/* Place each action on the arc. angle_i = start + span·(i + 0.5)/N; the polar
@ -266,12 +266,12 @@
inset-inline-start: 50%;
inset-block-start: 50%;
--_a: calc(
var(--menu-dial-arc-start, 270deg) +
var(--menu-dial-arc-span, 90deg) * (var(--menu-dial-i, 0) + 0.5) / var(--_dial-n)
var(--_menu-dial-arc-start, 270deg) +
var(--_menu-dial-arc-span, 90deg) * (var(--_menu-dial-i, 0) + 0.5) / var(--_dial-n)
);
--_ac: calc(var(--_a) - 90deg);
translate: calc(-50% + var(--menu-dial-radius) * cos(var(--_ac)))
calc(-50% + var(--menu-dial-radius) * sin(var(--_ac)));
translate: calc(-50% + var(--_menu-dial-radius) * cos(var(--_ac)))
calc(-50% + var(--_menu-dial-radius) * sin(var(--_ac)));
}
/* ── Trigger glyph (`+`→`×`) ──────────────────────────────────────────── */
@ -308,7 +308,7 @@
position: fixed;
inset: 0;
z-index: calc(var(--z-index-sticky, 1100) - 1);
background: var(--menu-dial-backdrop-bg);
background: var(--_menu-dial-backdrop-bg);
animation: menu-dial-backdrop-in var(--duration-fast, 150ms) var(--ease-out, ease-out) both;
}

@ -263,7 +263,7 @@
void open;
const n = actionEls().length;
actionCount = n;
frameEl.style.setProperty('--menu-dial-count', String(n));
frameEl.style.setProperty('--_menu-dial-count', String(n));
});
// Focus the first/queued action once the fan is visible.
@ -306,7 +306,7 @@
data-size={resolvedSize}
data-state={open ? 'open' : 'closed'}
data-stagger={stagger ? '' : undefined}
style={radius ? `--menu-dial-radius: ${radius};` : undefined}
style={radius ? `--_menu-dial-radius: ${radius};` : undefined}
onpointerenter={onPointerEnter}
onpointerleave={onPointerLeave}
onfocusout={onFocusout}

@ -168,8 +168,8 @@
[data-month-grid-prev-button]:focus-visible,
[data-month-grid-next-button]:focus-visible,
[data-month-grid-cell]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-month-grid-prev-button][data-disabled],

@ -64,6 +64,9 @@
// ── Soma behaviour bridge: perceptual emission + keyboard dispatch ──────
const eidos = ActiveEidos.require();
// Accessible name of the radial menu surface — routes through langs
// (morfo's `texts.label`). No hardcoded English.
const surfaceLabel = $derived(eidos.langs.ts('#?components.onion-menu.label|Onion menu'));
const runtime = createOnionMenuRuntime({
dom: eidos.dom,
eventEngine: eidos.uix?.events,
@ -396,7 +399,7 @@
tabindex="-1"
data-onion-menu-surface
data-state={open ? 'open' : 'closed'}
aria-label="Onion menu"
aria-label={surfaceLabel}
onkeydown={onSurfaceKeydown}
>
<defs>

@ -77,7 +77,8 @@
[data-pagination-first-trigger]:focus-visible,
[data-pagination-last-trigger]:focus-visible,
[data-pagination-item]:focus-visible {
box-shadow: var(--pagination-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-pagination-prev-trigger][data-disabled],

@ -29,7 +29,6 @@
* need its own state selectors — focused / invalid just rewrite them. */
--_password-field-border-color: var(--password-field-border);
--_password-field-bg: var(--password-field-bg);
--_password-field-box-shadow: none;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
@ -63,7 +62,6 @@
border: var(--password-field-border-width) solid var(--_password-field-border-color);
border-radius: var(--password-field-radius);
background: var(--_password-field-bg);
box-shadow: var(--_password-field-box-shadow);
pointer-events: none;
}
@ -182,7 +180,13 @@
[data-password-field][data-focused] {
--_password-field-border-color: var(--password-field-border-focus);
--_password-field-box-shadow: var(--password-field-focus-shadow);
}
/* Focus ring as a flush outline on the chrome pseudo-element (no offset → no gap),
so it survives forced-colors / HCM — replaces the old box-shadow ring. */
[data-password-field][data-focused]::before {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-password-field][data-invalid] {
@ -246,8 +250,8 @@
}
[data-password-field-visibility-trigger]:focus-visible {
outline: none;
box-shadow: var(--password-field-trigger-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-password-field-visibility-trigger][data-disabled] {

@ -63,7 +63,9 @@
[data-pin-input-cell][data-active] {
border-color: var(--pin-input-cell-active-border, var(--color-primary-solid));
box-shadow: var(--focus-ring);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-pin-input-cell][data-disabled] {

@ -99,7 +99,8 @@
}
[data-radio-cards] [data-radio-cards-item]:focus-visible {
box-shadow: var(--radio-cards-focus-ring);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
/* ── Checked — active border + faint tint ────────────────────────────────

@ -120,7 +120,8 @@
}
[data-radio-group-item]:focus-visible {
box-shadow: var(--radio-group-focus-ring);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
/* ── State: checked — solid (default) ──────────────────────────────────── */

@ -225,8 +225,8 @@
[data-range-calendar-month-select]:focus-visible,
[data-range-calendar-year-select]:focus-visible,
[data-range-calendar-day]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-range-calendar-prev-button][data-disabled],

@ -44,7 +44,8 @@
[data-rating-group]:focus-visible {
border-radius: var(--rating-group-focus-radius);
box-shadow: var(--rating-group-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-rating-group][data-disabled] {

@ -10,6 +10,7 @@
* Composition: callers can pass a custom glyph via `children`, or a
* `child` snippet (full bypass of IconButton).
*/
import { ActiveEidos } from '$uix/eidos';
import * as SearchField from '$soma/components/search-field';
import { IconButton } from '$uix/eidos/components/icon-button';
import type { SearchFieldClearTriggerProps } from './types';
@ -20,9 +21,14 @@
variant = 'ghost',
size,
rounded,
'aria-label': ariaLabel = 'Clear search',
'aria-label': ariaLabel,
...rest
}: SearchFieldClearTriggerProps = $props();
const eidos = ActiveEidos.require();
// Accessible name routes through langs (morfo's translationRef) — the
// explicit prop wins, else the localized default. No hardcoded English.
const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts('#?components.search-field.clear|Clear search'));
</script>
<SearchField.ClearTrigger {...rest}>
@ -37,7 +43,7 @@
{rounded}
intent="neutral"
color="neutral"
aria-label={ariaLabel ?? 'Clear search'}
aria-label={resolvedLabel}
>
{#if customGlyph}
{@render customGlyph()}

@ -119,7 +119,9 @@
[data-search-field][data-focused] {
border-color: var(--search-field-border-focus);
box-shadow: var(--search-field-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-search-field][data-invalid] {

@ -98,12 +98,14 @@
[data-select-trigger][data-state='open'] {
border-color: var(--_select-accent-border);
box-shadow: var(--select-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-select-trigger]:focus-visible {
border-color: var(--_select-accent-border);
box-shadow: var(--select-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-select-trigger][data-disabled],

@ -118,7 +118,11 @@
}
[data-slider-thumb]:focus-visible {
box-shadow: var(--slider-thumb-focus-shadow);
/* Keep the thumb's own drop shadow; the focus ring is a flush outline (no gap)
so it survives forced-colors. */
box-shadow: var(--slider-thumb-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-slider-thumb][data-disabled] {

@ -83,7 +83,9 @@
[data-spin-field][data-focused],
[data-spin-field][data-scrubbing] {
border-color: var(--spin-field-border-focus);
box-shadow: var(--spin-field-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-spin-field][data-invalid] {
@ -187,7 +189,8 @@
[data-spin-field-scrubber]:focus-visible {
background: var(--spin-field-control-bg-focus);
color: var(--spin-field-control-color-hover);
box-shadow: var(--spin-field-control-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-spin-field-increment-trigger][data-disabled],

@ -80,7 +80,8 @@
}
[data-stepper-trigger]:focus-visible {
box-shadow: var(--stepper-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-stepper-indicator] {
@ -160,7 +161,8 @@
[data-stepper-content]:focus-visible,
[data-stepper-completed-content]:focus-visible {
box-shadow: var(--stepper-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-stepper-prev-trigger],
@ -197,7 +199,8 @@
[data-stepper-prev-trigger]:focus-visible,
[data-stepper-next-trigger]:focus-visible {
box-shadow: var(--stepper-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-stepper-trigger][data-disabled],

@ -11,10 +11,6 @@
--_switch-track-bg-hover: var(--switch-track-bg-off-hover);
--_switch-track-border-hover: var(--switch-track-border-off-hover);
--_switch-thumb-offset: 0px;
--_switch-focus-ring:
0 0 0 var(--switch-focus-ring-offset) var(--switch-focus-ring-surface),
0 0 0 calc(var(--switch-focus-ring-offset) + var(--switch-focus-ring-width))
var(--switch-focus-ring-color);
position: relative;
inline-size: var(--_switch-track-width);
@ -78,7 +74,8 @@
}
[data-switch]:focus-visible {
box-shadow: var(--_switch-focus-ring);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-switch][data-disabled],

@ -5,6 +5,7 @@
* chevron that rotates to down when the panel is open. The accessible
* name comes from `aria-label` (the glyph carries no text).
*/
import { ActiveEidos } from '$uix/eidos';
import * as Table from '$soma/components/table';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgChevron } from '$uix/eidos/components/svg';
@ -19,12 +20,16 @@
intent,
color,
icon: customIcon,
'aria-label': ariaLabel = 'Toggle row details',
'aria-label': ariaLabel,
...rest
}: TableRowDetailTriggerProps = $props();
const eidos = ActiveEidos.require();
const tableCtx = getTableEidosContext();
const resolvedSize = $derived(size ?? tableCtx?.size ?? 'md');
// Accessible name routes through langs (morfo's translationRef) — the
// explicit prop wins, else the localized default. No hardcoded English.
const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts('#?components.table.toggle-detail|Toggle detail'));
</script>
<Table.RowDetailTrigger {...rest}>
@ -33,7 +38,7 @@
{#if outerChild}
{@render outerChild({ props })}
{:else}
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={ariaLabel}>
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={resolvedLabel}>
{#if customIcon}{@render customIcon()}{:else}<SvgChevron direction={isOpen ? 'down' : 'right'} />{/if}
</IconButton>
{/if}

@ -93,7 +93,8 @@
[data-tag-group-item]:focus-visible,
[data-tag-group-link]:focus-visible,
[data-tag-group-remove-button]:focus-visible {
box-shadow: var(--tag-group-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-tag-group-item][data-state='selected'],

@ -95,7 +95,9 @@
[data-tags-input-control][data-focus] {
border-color: var(--_tags-input-accent-border);
box-shadow: var(--tags-input-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-tags-input-control][data-invalid] {
@ -143,7 +145,8 @@
border-color: var(--_tags-input-accent-border);
background: var(--_tags-input-accent-track);
color: var(--_tags-input-accent-text);
box-shadow: var(--tags-input-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-tags-input-item-text] {
@ -192,7 +195,8 @@
[data-tags-input-item-delete-trigger]:focus-visible,
[data-tags-input-clear-trigger]:focus-visible {
box-shadow: var(--tags-input-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-tags-input][data-disabled],

@ -118,9 +118,10 @@
[data-textarea][data-focused] [data-textarea-input] {
border-color: var(--_textarea-border-focus);
/* Canonical two-ring focus model (inner inset 0 + outer ring) — same as every
other input, so the textarea matches the rest. */
box-shadow: var(--focus-ring);
/* Focus ring as a flush outline (no offset → no gap) — same as every other input,
and survives forced-colors / HCM (box-shadow rings don't). */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-textarea-input][data-invalid] {

@ -114,10 +114,13 @@
border-radius: var(--time-field-radius);
background: var(--time-field-bg);
color: inherit;
transition:
background var(--time-field-transition-duration) var(--time-field-transition-ease),
border-color var(--time-field-transition-duration) var(--time-field-transition-ease),
box-shadow var(--time-field-transition-duration) var(--time-field-transition-ease);
/* Only `background` transitions. `border-color` must be INSTANT: the focus
border toggles via `:has(segment:focus-visible)`, and a keyboard increment
briefly blurs/refocuses the segment — a transitioned border-color would
fade out/in on every step (visible flicker). The focus ring is an
`outline` (instant). `box-shadow` is no longer used here (post focus-ring
migration) — dropped from the transition. */
transition: background var(--time-field-transition-duration) var(--time-field-transition-ease);
}
[data-time-field][data-variant='outline'] [data-time-field-input] {
@ -132,7 +135,9 @@
[data-time-field-input]:has([data-time-field-segment]:focus-visible) {
border-color: var(--_time-field-accent-border);
box-shadow: var(--time-field-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-time-field][data-invalid] [data-time-field-input],

@ -114,9 +114,10 @@
}
[data-time-picker-trigger]:focus-visible {
outline: none;
border-color: var(--_time-field-accent-border);
box-shadow: var(--time-field-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-time-picker-trigger][data-disabled],
@ -288,8 +289,10 @@
[data-time-picker-clock] [data-slider-thumb]:focus-visible {
box-shadow:
var(--slider-thumb-focus-shadow),
var(--slider-thumb-shadow),
0 0 0 2px var(--color-surface-default);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-time-picker-clock] [data-slider-range] {

@ -131,9 +131,10 @@
}
[data-time-range-picker-trigger]:focus-visible {
outline: none;
border-color: var(--_time-field-accent-border);
box-shadow: var(--time-field-focus-shadow);
/* Focus ring as a flush outline (no offset → no gap) — survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-time-range-picker-trigger][data-disabled],
@ -305,8 +306,10 @@
[data-time-range-picker-clock] [data-slider-thumb]:focus-visible {
box-shadow:
var(--slider-thumb-focus-shadow),
var(--slider-thumb-shadow),
0 0 0 2px var(--color-surface-default);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-time-range-picker-clock] [data-slider-range] {

@ -124,7 +124,8 @@
[data-toolbar-button]:focus-visible,
[data-toolbar-link]:focus-visible,
[data-toolbar-group-item]:focus-visible {
box-shadow: var(--toolbar-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-toolbar-group-item][data-state='on'] {

@ -5,6 +5,7 @@
* rotates by `data-state` (open/closed). The accessible name comes from
* `aria-label` (the glyph carries no text).
*/
import { ActiveEidos } from '$uix/eidos';
import * as TreeGrid from '$soma/components/tree-grid';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgChevron } from '$uix/eidos/components/svg';
@ -19,12 +20,16 @@
intent,
color,
icon: customIcon,
'aria-label': ariaLabel = 'Toggle row',
'aria-label': ariaLabel,
...rest
}: TreeGridExpandTriggerProps = $props();
const eidos = ActiveEidos.require();
const ctx = getTreeGridEidosContext();
const resolvedSize = $derived(size ?? ctx?.size ?? 'md');
// Accessible name routes through langs (morfo's translationRef) — the
// explicit prop wins, else the localized default. No hardcoded English.
const resolvedLabel = $derived(ariaLabel ?? eidos.langs.ts('#?components.tree-grid.toggle|Toggle row'));
</script>
<TreeGrid.ExpandTrigger {...rest}>
@ -32,7 +37,7 @@
{#if outerChild}
{@render outerChild({ props })}
{:else}
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={ariaLabel}>
<IconButton {...props} {variant} size={resolvedSize} {rounded} {intent} {color} aria-label={resolvedLabel}>
{#if customIcon}{@render customIcon()}{:else}<SvgChevron direction="right" />{/if}
</IconButton>
{/if}

@ -168,8 +168,8 @@
[data-year-grid-prev-button]:focus-visible,
[data-year-grid-next-button]:focus-visible,
[data-year-grid-cell]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-year-grid-prev-button][data-disabled],

@ -46,7 +46,7 @@
--list-item-py: var(--space-1);
--list-item-px: var(--space-2);
--list-item-gap: var(--space-2);
--list-content-padding: var(--space-1);
--list-content-padding: var(--space-1-5);
--list-content-radius: var(--radius-md);
}
@ -56,7 +56,7 @@
--list-item-py: var(--space-1-5);
--list-item-px: var(--space-2-5);
--list-item-gap: var(--space-2-5);
--list-content-padding: var(--space-1-5);
--list-content-padding: var(--space-2);
--list-content-radius: var(--radius-md);
}
@ -66,6 +66,6 @@
--list-item-py: var(--space-2);
--list-item-px: var(--space-3);
--list-item-gap: var(--space-3);
--list-content-padding: var(--space-2);
--list-content-padding: var(--space-2-5);
--list-content-radius: var(--radius-md);
}

@ -13,6 +13,7 @@ export const commonLangs = {
clear: { es: 'Limpiar todo', en: 'Clear all' },
close: { es: 'Cerrar', en: 'Close' },
decrement: { es: 'Disminuir', en: 'Decrement' },
dismiss: { es: 'Descartar', en: 'Dismiss' },
edit: { es: 'Editar', en: 'Edit' },
increment: { es: 'Aumentar', en: 'Increment' },
next: { es: 'Siguiente', en: 'Next' },

@ -12,5 +12,21 @@ export const cropperLangs = {
selection: {
es: 'Región de recorte',
en: 'Crop region'
},
'zoom-out': {
es: 'Alejar',
en: 'Zoom out'
},
zoom: {
es: 'Zoom',
en: 'Zoom'
},
'zoom-in': {
es: 'Acercar',
en: 'Zoom in'
},
'reset-zoom': {
es: 'Restablecer zoom',
en: 'Reset zoom'
}
} satisfies LangNode;

@ -23,6 +23,7 @@
const provider = TimePickerProvider.require();
const amLabel = provider.soma?.langs.ts(TIME_PICKER_LANGS.AM) ?? 'AM';
const pmLabel = provider.soma?.langs.ts(TIME_PICKER_LANGS.PM) ?? 'PM';
const dayPeriodLabel = provider.soma?.langs.ts(TIME_PICKER_LANGS.DAY_PERIOD) ?? 'AM/PM';
const groupName = $derived(`${id}-group`);
@ -36,7 +37,7 @@
mergeProps(restProps, {
[timePickerAttrs['day-period-toggle']]: '',
role: 'radiogroup',
'aria-label': 'AM/PM',
'aria-label': dayPeriodLabel,
'aria-disabled': disabled || undefined
} as Record<string, unknown>)
);

@ -4,5 +4,6 @@ export const TIME_PICKER_LANGS = {
MINUTE_SLIDER: '#?common.time.minute|Minute',
SECOND_SLIDER: '#?common.time.second|Second',
AM: '#?common.time.am|AM',
PM: '#?common.time.pm|PM'
PM: '#?common.time.pm|PM',
DAY_PERIOD: '#?common.time.day-period|AM/PM'
} as const;

Loading…
Cancel
Save

Powered by TurnKey Linux.