feat(eidos): field-family font-scaling coherence (input 1:1 + label step-down)

- Field font scale: input 1:1 (size N -> --font-size-N, md now 16px), label one
  step below (10/12/14/16/18). Recipes: field, spin-field, date/time/color-field,
  search-field, password-field, select, editable, tags-input. Segmented-field
  labels via CSS calc; generic Field label via label-font-size tokens.
- Picker triggers (date/time/range): box now scales off the local
  --_X-field-height + font:inherit so the 1em icon follows the field font; added
  the missing xs size variant to time-picker + time-range-picker.
- color-field/time-field commit-set fires + sounds on increment (compare by
  ColorValue.hex; time-field passes fallbackTarget for the double-registered
  'input' part).
- color-field format-select: reverted the segment-active tint back to its focus ring.
- Docs: THEMING.md field font rule; COMPONENT_COHERENCE_AUDIT.md.

words / palabras / chronos untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 46952dd6fa
commit 8f84b36f6d

@ -4,14 +4,8 @@
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
},
{
"name": "dev-preview",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5199", "--strictPort"],
"port": 5199
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"port": 5180
}
]
}

@ -348,7 +348,64 @@
"Bash(awk 'NR>=300 && NR<=345' src/uix/eidos/components/palabras/palabras.css)",
"Bash(awk '/\\\\[data-palabras-panel-card\\\\] \\\\{/{f=1} f{print NR\": \"$0} /^\\\\}/{if\\(f\\){f=0}}' src/uix/eidos/components/palabras/palabras.css)",
"mcp__Claude_in_Chrome__get_page_text",
"Bash(Select-Object -Last 30)"
"Bash(Select-Object -Last 30)",
"Bash(awk 'NR>=1960 && NR<=2009 && /^\\\\t[a-z][a-z-]*: \\\\{/ {name=$0} END{print name}' src/uix/eidos/lib/recipes/base.ts)",
"Bash(awk 'NR>=1980 && NR<=2009 && /^\\\\t[a-zA-Z][a-zA-Z-]*: \\\\{/ {print NR\": \"$0}' src/uix/eidos/lib/recipes/base.ts)",
"Bash(awk -F: '$1>=1700 && $1<=2100 {print}')",
"Bash(awk -F: '$1>=1850 && $1<=2009')",
"Bash(awk 'NR>=300 && NR<=340 && /: \\\\{/ {k=$0} /iconSize: .xxl./ {print NR\": owned by → \"k}' src/uix/eidos/lib/primitives/static.ts)",
"Bash(awk 'NR>=315 && NR<=335 && /: \\\\{/ {k=$0} /iconSize: .\\(xl|xxl\\)./ {print NR\": \"k\" → \"$0}' src/uix/eidos/lib/primitives/static.ts)",
"Bash(rg 'from '\\\\''\\\\$lib'\\\\''|from \"\\\\$lib\"|from '\\\\''\\\\$lib/' src/ -n --glob !**/node_modules/**)",
"Bash(awk -F: '$1 < 427')",
"Bash(awk -F: '$1 < 848')",
"Bash(rg -n --no-messages \"data-radio-cards-icon|<Icon|radio-cards.*Icon\" web/routes)",
"Bash(rg -ln --no-messages \"RadioCards\" web/routes)",
"Bash(rg -n --no-messages \"Icon|svg|<RadioCards.Icon\" web/routes/uix/components/radio-cards/+page.svelte)",
"Bash(rg -ln --no-messages \"color-surface-subtle|leading-body|font-family-body|segment-focus-shadow\" src/uix/eidos --glob \"*.test.ts\")",
"Bash(echo \"exit: $?\")",
"Bash(rg -ln --no-messages \"consumed.*defined|undefined.*token|orphan\" src/uix/eidos --glob \"*.test.ts\")",
"Bash(echo \"exit-grep=$?\")",
"Bash(awk 'NR>=1100 && NR<=1160 && /^\\\\t[a-z-]+:|recipe|component:|name:/' src/uix/eidos/lib/recipes/base.ts)",
"Bash(awk -F/ '{print $1\"/\"$2}')",
"Bash(awk -F/ '{ if \\($1==\"src\"\\) print \"src/\"$2\"/\"$3; else print $1 }')",
"Bash(grep -nE 'validateMorfo|export \\(async \\)?function loadMorfos|testTimeout|import' src/uix/eidos/../../../scripts/soma-attr-audit.ts 2>/dev/null | head; echo '--- direct run audit loadMorfos timing ---'; timeout 60 node --import tsx/esm -e \"import\\('./scripts/soma-attr-audit.ts'\\).then\\(async m=>{const t=Date.now\\(\\);const ms=await m.loadMorfos\\(\\);console.log\\('loaded',ms.length,'in',Date.now\\(\\)-t,'ms'\\)}\\).catch\\(e=>console.log\\('ERR',e.message\\)\\)\" 2>&1 | tail -5)",
"PowerShell(npm run check 2>&1)",
"PowerShell(1..3 | ForEach-Object { Write-Output \"=== Run $_ ===\"; npx vitest run src/uix/soma/soma-attr-audit.test.ts 2>&1 | Select-String -Pattern \"passed|failed|timed out|Duration\" })",
"Bash(cd /g/dev/svelte/vicen)",
"Bash(ls src/libs/timer/)",
"Bash(ls src/arts/timer/*.md)",
"Bash(find src/arts/timer src/libs/timer -name '*.test.ts')",
"Bash(npx rg *)",
"PowerShell(npx prettier --write \"src/uix/eidos/MOTION_SERVICE_RFC.md\")",
"PowerShell(npx prettier --check \"src/uix/eidos/MOTION_SERVICE_RFC.md\")",
"Bash(node_modules/.bin/prettier --write src/arts/sium)",
"Bash(node_modules/.bin/prettier --check src/arts/sium)",
"Bash(rg -ln 'from '\\\\''\\\\$uix/eidos'\\\\''' web/routes)",
"Bash(rg -oN \"var\\\\\\(--\\(space|radius|color|font-size|line-height|shadow|duration\\)-[a-z0-9-]+\\\\\\)\" src/uix/eidos/components/date-picker/*.css src/uix/eidos/components/button/*.css)",
"Bash(rg -oN \"var\\\\\\(--\\(space|radius|font-size|line-height|shadow|color-neutral|color-surface|color-text|focus|duration\\)-[a-z0-9-]+\\\\\\)\" src/uix/eidos/components/**/*.css)",
"Bash(ls web/routes/uix/components/demos/)",
"Bash(cat \"C:/Users/dev/AppData/Local/Temp/claude/G--dev-svelte-vicen/80e4dfad-c7bf-4627-b0ea-9337414354c7/tasks/b8u5eude9.output\")",
"Bash(echo \"exit morfo:check: $?\")",
"Bash(rg -i \"chronos|components\\\\\\\\\\\\\\\\demos\")",
"Bash(cd \"G:/dev/svelte/vicen\" && npm run morfo:check > /tmp/mc.txt 2>&1; ec=$?; echo \"morfo:check EXIT=$ec\"; echo \"=== linea qr-code ===\" && grep -iE \"qr-code|invariant|fail|FAIL|✗\" /tmp/mc.txt | head)",
"Bash(rg -rln \"RecipeTokens|defineRecipe|recipe\" src/uix/eidos/components/calendar/)",
"Bash(rg -i \"chronos|recipes\\\\\\\\\\\\\\\\base|sema\\\\\\\\\\\\\\\\components\\\\\\\\\\\\\\\\index\")",
"Bash(node_modules/.bin/prettier --write src/arts/connection/index.ts src/arts/http/consts.ts src/arts/http/diagnostics.ts src/arts/color/index.ts)",
"Bash(node_modules/.bin/prettier --check src/arts/connection src/arts/http src/arts/color)",
"Bash(node_modules/.bin/prettier --check src/arts/connection/index.ts src/arts/http/consts.ts src/arts/http/diagnostics.ts src/arts/color/index.ts)",
"Bash(node_modules/.bin/prettier --write docs/process/arts-audit-2026-06.md docs/process/README.md)",
"Bash(node_modules/.bin/prettier --check src/arts/connection/index.ts src/arts/http/consts.ts src/arts/http/diagnostics.ts src/arts/color/index.ts docs/process/arts-audit-2026-06.md docs/process/README.md)",
"Bash(rg -ln 'from '\\\\''\\\\$uix/eidos/components/button'\\\\''' src/uix/eidos/components/)",
"Bash(rg -n \"Button|child\" src/uix/eidos/components/date-picker/*.svelte)",
"Bash(rg -n \"Soma|getActiveUix|ActiveEidos\" src/uix/eidos/components/icon/*.svelte)",
"Bash(node_modules/.bin/prettier --write src/arts/connection/types.ts src/arts/connection/engine-connections.ts)",
"Bash(node_modules/.bin/prettier --check src/arts/connection/types.ts src/arts/connection/engine-connections.ts)",
"Bash(rg -rn \"toCalendarDateTime|export .*\\\\bTime\\\\b|toCalendarDate\" src/libs/days/)",
"Bash(sed -n '1,50p' src/uix/eidos/components/drag-drop/index.ts)",
"Bash(sed -n '1,40p' src/uix/soma/components/drag-drop/exports.ts)",
"Bash(rg -i \"chronos-view|chronos\\\\\\\\\\\\\\\\chronos|components.chronos|components.demos|drag-drop\")",
"Bash(echo \"=== ad-hoc opacity \\(non 0/1\\) with their selector context ===\")",
"Bash(awk ' *)"
],
"additionalDirectories": [
"\\tmp"

7
package-lock.json generated

@ -12,6 +12,7 @@
"@floating-ui/dom": "^1.7.1",
"csstype": "^3.2.3",
"esm-env": "^1.1.2",
"ogl": "^1.0.11",
"runed": "0.37.1",
"tabbable": "^6.2.0"
},
@ -2564,6 +2565,12 @@
],
"license": "MIT"
},
"node_modules/ogl": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ogl/-/ogl-1.0.11.tgz",
"integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==",
"license": "Unlicense"
},
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",

@ -54,6 +54,7 @@
"@floating-ui/dom": "^1.7.1",
"csstype": "^3.2.3",
"esm-env": "^1.1.2",
"ogl": "^1.0.11",
"runed": "0.37.1",
"tabbable": "^6.2.0"
}

@ -0,0 +1,416 @@
---
title: Auditoría de coherencia de componentes — eidos
type: audit
date: 2026-06-16
scope: src/uix/eidos/components/* (excl. words · palabras · chronos)
method: grep + Read dirigidos (sin agentes, por instrucción del usuario)
status: FASE 1 (catálogo) — completa. FASE 2 (remediación) pendiente de lotes.
---
# Auditoría de coherencia de componentes — eidos
Catálogo de incoherencias a nivel de componente: partes cableadas a mano en vez
de componer el ecosistema, literales que se saltan el tema, coherencia de
contrato y verificación de morfo. **Excluidos: words, palabras, chronos.**
116 componentes barridos.
## Resumen ejecutivo
| Dimensión | Veredicto | Hallazgos |
|---|---|---|
| **1. Composición** | 🔴 grande | 6 `<select>` nativos · ~16 componentes reimplementan chrome de botón-comando · ~14 ya correctos (referencia) |
| **2. Literales que se saltan el tema** | 🟡 medio | ~250 literales px/rem; pocos 🔴 bypass real, muchos 🟡 off-scale interno, mayoría 🟢 idiomas legítimos |
| **3. Coherencia de contrato** | 🟢 limpio | `eidos-lint`: **0 invalid en los 116** — el typed-builder + lint funcionan |
| **4. Verificación de morfo** | 🟢 casi limpio | morfos compilan; `unused` informativo; 1 ítem: `corner-shape` documentado-no-materializado |
| **5. Homogeneidad transversal** | 🔴 alto | **focus-ring** 2 mecanismos + a11y forced-colors · **fondo de fields** no homogéneo (editable/tags-input raised, textarea fantasma, combobox hardcode) · **segmentos date/time/color** estado activo divergente + anillo + flicker al teclear · **modelo de anchura** (block-opt-in vs 100%-siempre → fields fijos desproporcionados) · nits API |
El problema real es **composición** (dim. 1), la **higiene de literales** (dim. 2)
y el **focus-ring no homogéneo** (dim. 5). Contrato y morfo (dim. 3/4) están sanos
— no se fabrican problemas ahí.
---
## Decisiones de diseño (cerradas con el usuario, 2026-06-16)
1. **Clave de clasificación = la FAMILIA del evento en el morfo + el ESTADO del
part** (no la impresión visual). Anclado al libro: la familia es intrínseca al
acto y fija su firma perceptual; `<Button>` empaqueta `contact.activate`.
2. **Política ternaria** por parte interactiva:
- **Botón-comando sin estado** (close · clear · prev/next · inc/dec · step ·
download · action) → **componer `<Button>`** (familia `contact.activate`;
la consecuencia de dominio —`emerge.close`, `shift.navigate`— la cablea el
consumidor).
- **Superficie CON estado** (porta `aria-selected`/`expanded`/`current`;
familia `commit`/`emerge`/`shift` sobre el control) → **identidad
estructural** (`data-button` + tokens del recipe Button), mantiene su
role/aria/keyboard/sema. Precedente: **toggle-group** (`data-toggle`).
- **Chrome propio** (calendar-day = `gridcell`) → **bespoke** pero
consumiendo tokens.
3. **`IconButton` es un primitivo público del framework** (no adorno; MUI/Radix/
Chakra lo tienen). Vía (a): **se quita `iconOnly` del API público de `Button`**
(pasa a modo interno), `IconButton` es EL componente icon-only — **compone
`Button` por dentro** (hereda variant/size/intent/color/rounded/loading +
morfo + sema; sin morfo nuevo), **añade `aria-label` obligatorio por tipo** +
el glifo como contenido + el layout cuadrado. Es **agnóstico al glifo**. Se
**migran los 14** `<Button iconOnly>` actuales (corte limpio, sin shim).
NO se hace `CloseButton` ni un componente-por-glifo (el glifo es prop/children).
4. **Fuente de glifos = por animabilidad, no por capa**:
- **`svg/`** = glifos de affordance **propios y animables/acoplables a estado**
(chevron rota, check/dash dibujan vía `pathLength`+`data-svg`, dot, y el `×`
que se animará al cerrar). Tuned, hooks `data-svg`.
- **`icon/` (1696 lucide)** = glifos de **contenido estático** para el
consumidor / chrome estático puntual. No tuneados.
- Regla: *¿se animará o acopla a estado? → `svg/`. ¿Contenido estático? →
`icon/` lucide.* → faltan `SvgX` (+ `SvgPlus`/`SvgMinus`) en `svg/`.
5. **Regla de radio theme-driven**: todo radio —incluido `full` y el default—
resuelve a `--radius-*`; cero literales (`9999px` → `var(--radius-full)`).
Aplica a magnitud (`rounded`) y forma (`corner-shape`).
---
## DIMENSIÓN 1 — Composición
### 1A — `<select>` NATIVO → componer `<Select>` (🔴 estructural)
`<select><option>` nativo donde el ecosistema tiene `<Select>` compuesto
(Trigger/Value/Content/Item…). Migrarlo cambia morfo (archetype `input` →
composite), soma (render `<Select.*>`), DOM y a11y.
| Componente | Parte | Morfo hoy | Fichero soma |
|---|---|---|---|
| calendar | month-select, year-select | archetype `input`, element `select` | `soma/.../calendar-month-select.svelte` + year |
| range-calendar | month-select, year-select | idem | `soma/.../range-calendar-{month,year}-select.svelte` |
| color-field | format-select | `input`/`select` | `soma/.../color-field-format-select.svelte` |
| color-picker | format-select | `input`/`select` | `soma/.../color-picker-format-select.svelte` |
**Síntomas del chrome nativo a resolver al migrar** (observados en color-field, 2026-06-16):
- Con `appearance: auto`, al estar **activo/enfocado** el `<select>` pinta el
**borde/outline negro nativo del navegador** — no tiene sentido visual y rompe
la coherencia con el resto del field. Desaparece al componer `<Select>` (que usa
el focus-ring del sistema, themeable). Vale para los 6 sitios.
- (Histórico) se le había aplicado por error el resaltado de segmento-activo
(`--field-segment-active-*`) en `:focus-visible`; revertido a su anillo propio
el 2026-06-16. La migración a `<Select>` lo hace moot.
### 1B — Botón-comando que REIMPLEMENTA chrome → componer `<Button>` (🔴)
Parte stateless de comando con border/bg/hover/focus-visible/disabled reescrito
en su CSS, en vez de componer `<Button>` vía soma `child`. Familia del part =
`contact.activate` (la consecuencia de dominio la cablea el consumidor).
| Componente | Parte(s) | Confirmado |
|---|---|---|
| calendar | prev-button, next-button | ✓ leído (`--calendar-control-*` chrome completo) |
| range-calendar | prev/next | ✓ análogo a calendar |
| dialog | close | ✓ no compone Button |
| drawer | close | ✓ no compone Button |
| popover | close | ✓ no compone Button |
| banner | close | ✓ no compone Button |
| toast | close | ✓ no compone Button |
| float-panel | close (+ controles) | ✓ no compone Button |
| pagination | prev/next nav | candidato (page-items van a 1D) |
| stepper | step nav | candidato |
| spin-field | increment / decrement | candidato (ver Lote 0: spin-field es el visual compartido) |
| editable | edit / submit / cancel | candidato |
| file-upload | browse / trigger | candidato |
| image-picker | trigger / controles | candidato |
| color-picker | eyedropper / controles de acción | candidato |
| tags-input | clear (remove-tag → 1D, es chip) | candidato |
| color-field | clear | candidato |
> "candidato" = grep marca chrome de botón sin composición de Button; confirmar
> el part exacto al abrir el wrapper en Lote A.
### 1C — YA correctos (componen `<Button>`) — referencia/plantilla
`alert-dialog` (action/cancel) · `button` · `card` (footer) · `carousel`
(prev/next) · `clipboard` (trigger) · `combobox` (clear) · `dropdown-menu`
(trigger) · `link-preview` (trigger) · `navigation-menu` (link/trigger) ·
`picker-shell` (close/clear/cancel) · `qr-code` (download) · `search-field`
(clear) · `table` (row-detail) · `tree-grid` (expand).
Plantilla canónica: `components/carousel/carousel-prev-trigger.svelte`
(`<Button iconOnly variant='ghost' rounded='full'>` + `<SvgChevron>` vía soma
`child`, size desde contexto).
### 1D — Superficie CON estado → identidad estructural (🟡 polish, no bug)
Portan estado de dominio (`aria-selected`/`expanded`/`current`/`pressed`); su
familia es `commit`/`emerge`/`shift` SOBRE el control. **No** componen Button;
si quieren su look → `data-button` + tokens del recipe (vía toggle-group). Si
tienen chrome propio coherente, se quedan bespoke-tokenizados.
| Componente | Parte | Familia | Acción |
|---|---|---|---|
| toggle-group | item | commit/toggle | ✓ ya identidad estructural (`data-toggle`) |
| tabs | trigger | commit/select | identidad estructural (look de Button) — opcional |
| accordion | header/trigger | emerge | bespoke disclosure (chrome propio) |
| pagination | page-item | commit | identidad estructural — opcional |
| calendar / month-grid / year-grid | day/cell | commit/shift | bespoke `gridcell` (tokenizado) |
| listbox / select / combobox / menubar / context-menu / dropdown-menu | item | commit | bespoke item (tokenizado) |
| radio-cards / rating-group | card/item | commit | bespoke (tokenizado) |
| tree-view / tree-grid | node | commit/emerge | bespoke (tokenizado) |
---
## DIMENSIÓN 2 — Literales que se saltan el tema
~250 literales px/rem en value-position (excl. `var()`, `0`). Tres clases:
### 2A — 🔴 Bypass real (el token EXISTE y se ignora)
| Fichero | Literal | Debe ser |
|---|---|---|
| `button/button.css:202` | `--_button-radius: 9999px` (`rounded='full'`) | `var(--radius-full)` |
| `grid-list/grid-list.css:203` | `border-radius: 0.2rem` | `var(--radius-sm)` (o token nombrado) |
| `navigation-menu/navigation-menu.css:292` | `border-radius: 1px` (indicador) | `var(--radius-sm)` o token del indicador |
### 2B — 🟡 Off-scale interno (NO existe token; decidir por doctrina anti-mágicos)
Micro-escalas component-internas sin equivalente en la escala. Opción: snap a
escala existente, o token de recipe nombrado y justificado.
- **Escalas de tamaño internas**: `spinner` (`--_spinner-size` 0.75–2.5rem +
thickness 1.5–4px), `carousel` (`--_carousel-indicator-size` 0.375–0.75rem),
`link-preview` (`--_link-preview-min-width` 11–22rem), `color-picker`
(`--cp-checker-cell: 6px`).
- **Focus ring offset**: `outline-offset: 1px|2px` recurre en muchos (link,
button, navigation-menu…) → **candidato fuerte a token** `--focus-ring-offset`.
- **Micro-gaps/paddings sub-escala** (`<--space-1`=4px): `gap: 1px|2px`,
`padding: 2px` (time-range-picker, command, navigation-menu) → token
`--space-px`/`--space-0.5` o aceptar.
- **Geometría de barra/indicador**: `block-size: 2px` (navigation-menu, command).
- **Underline**: `text-decoration-thickness: 1px`, `text-underline-offset: 2px|3px`.
### 2C — 🟢 Idiomas legítimos (NO tocar)
- `sr-only`: `width:1px; height:1px; margin:-1px` (button, link, command,
time-range-picker…) — patrón canónico de visually-hidden.
- **`em`-relativos**: `0.85em`, `1.125em`, `0.875em`, `0.25em` — escalan con la
fuente local; no son bypass.
- **`50%` círculos**: cropper handle, search-field clear, spinner — idioma de
círculo perfecto sobre elemento cuadrado.
- **Deltas de keyframe** de motion: `translateY(4px)`, `translateX(12px)`,
`scale(0.985)` — offsets de animación.
- **`box-shadow: 0 0 0 9999px` (cropper mask)** — hack de overlay, no es radio.
### Top offenders por nº de literales (para priorizar lectura en Lote D)
`link-preview` 17 · `navigation-menu` 14 · `time-range-picker` 13 · `spinner`
12 · `link` 11 · `button` 10 · `tooltip` 9 · `carousel` 9 · `command` 8 ·
`color-picker` 8 · `tree-grid` 7 · `password-field` 7 — (la mayoría caen en
2B/2C; el 🔴 real es chico).
---
## DIMENSIÓN 3 — Coherencia de contrato
`scripts/eidos-lint-all.ts`: **`invalid = 0` en los 116 componentes.** Ningún
selector eidos targetea un attr con valor que el morfo no declara. El
typed-builder (`semaSelector`) + el lint cumplen su función. **Sin hallazgos.**
`eidos-only` (selectores que el morfo no declara pero son legítimos:
pseudo-estados, cross-component como `[data-popover-content] [data-year-grid]`,
attrs estructurales) y `unused` (el morfo declara attrs que el CSS no estiliza)
son informativos, no bugs.
---
## DIMENSIÓN 4 — Verificación de morfo
- Todos los morfos compilan limpios (el lint los carga sin error).
- **`unused` alto** en algunos (drawer 16, stepper 28, field 22, css-field 18,
number-field 18): el morfo declara attrs que su CSS no estiliza. Casi siempre
legítimo — los consume soma/sema o son state-only. `css-field`/`number-field`
tienen CSS propio vacío porque delegan en el visual compartido `spin-field`
(identidad estructural). 🟡 verificar puntualmente que ninguno sea declaración
muerta — baja prioridad.
- **`corner-shape`** (Button `types.ts`: `rounded`/`continuous`/`cut`/`scoop`):
documentado como prop pero **sin materialización encontrada en `button.css`**.
Posible prop-sin-implementar = incoherencia de contrato. 🟡 verificar/implementar.
---
## DIMENSIÓN 5 — Homogeneidad transversal (API + visual)
Consistencia del vocabulario y del aspecto entre componentes — lo que hace que la
librería se sienta "de una pieza". Verificado por grep de types.ts + CSS.
### 5A — Focus-ring NO homogéneo (🔴, headline)
El **valor** del foco es casi uniforme (`--color-focus-ring` = alias de
`--focus-ring-color`; los ~18 `--{c}-focus-shadow` son copias del composite
`--focus-ring`). Lo que rompe es el **mecanismo**:
| Mecanismo | Componentes | Nota |
|---|---|---|
| `outline: var(--focus-ring-width) solid var(--focus-ring-color)` | ~22 (badge, card, carousel, drawer, form, listbox, menubar, popover, table, tree-*, virtual-*…) | **el estándar** |
| `box-shadow: var(--{c}-focus-shadow)` (composite) | ~18 (checkbox, radio-group, radio-cards, select, slider, date-field, time-field, tags-input, toolbar, pagination, stepper, rating-group, breadcrumb, color-field, editable, file-upload, switch…) | **muere en forced-colors (HCM)** = regresión a11y |
| `outline: var(--border-width-medium) solid var(--color-primary-solid)` | button, link, code-block | 3ª convención (ancho + color distintos a los tokens de focus) |
| `outline … var(--color-focus-ring)` | cropper, image-adjustments, image-picker | token-alias redundante |
→ El anillo de foco **se ve y se comporta distinto** según el componente. **Fix**:
unificar todo a `outline: var(--focus-ring-width) solid var(--focus-ring-color)` +
`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 (🔴)
Afecta `date-field`, `time-field`, `color-field` (estructura idéntica) → se propaga a
`date-picker`/`time-picker`/`date-range-*` que los embeben.
**Estado activo del segmento — divergente y con borde:**
| Componente | Fondo activo | Anillo | Peso |
|---|---|---|---|
| date-field | `accent-element` (fuerte) | inset ring (`--ring-inset-width`) | 600 |
| time-field | `accent-track` (sutil) | `--time-field-segment-focus-shadow` | — |
| color-field | `accent-track` (sutil) | `--color-field-segment-focus-shadow` | — |
→ Los tres llevan anillo; date lo enmascara con fondo fuerte, time/color lo muestran.
**Objetivo (canónico)**: el segmento activo se resalta con **fondo** y **sin borde/anillo**.
**Fix**: quitar el `box-shadow` (inset ring + `--{c}-segment-focus-shadow`) en los tres;
unificar a UN token de fondo (decidir element vs track en browser) + `color: accent-text`
(+ peso consistente). `outline: none` se queda.
**Flicker del borde del campo al incrementar por teclado (bug):** `[data-{c}-field-input]`
transiciona `border-color`+`box-shadow`, y `:has([segment]:focus-visible)` aplica
borde+shadow al campo entero. Al incrementar, el `:focus-visible` del segmento parpadea
(re-render del segmento en soma → blur/refocus), el `:has()` salta off→on, y la
**transición** desvanece el borde fuera/dentro = el parpadeo. **Fix**: quitar
`border-color`+`box-shadow` de la `transition` del input (foco instantáneo). Si persiste
un blink de 1 frame, raíz secundaria = retención de foco del segmento en soma.
### 5F — Modelo de anchura: dos defaults opuestos + fields desproporcionados (🔴)
**Dos modelos de anchura coexisten:**
- **`block`-opt-in** (~10 componentes: button, toggle, toggle-group, carousel, feed,
grid-list, listbox, table, tree-grid, tree-view): default **content-width**, prop
`block` → `data-block` → `inline-size: 100%`.
- **`100%`-siempre** (familia field: date/time/color-field, number, css, search,
password, combobox, select, textarea, field, + pickers): hard-codean
`inline-size: 100%` en root + input, **sin opt-out** (no hay prop `block`).
→ Los fields de **contenido casi fijo** (date/time/color/pin) a 100% son
desproporcionados — un `12/31/2026` no necesita la columna entera; los pickers
(date/time) heredan el 100% del contenedor aunque su contenido sea estático. (Los
`max-content` existentes son del popover del calendario, no del trigger.)
**Fix (alinear al modelo del propio sistema)**: la familia field adopta el patrón
`block` —
- **Contenido fijo** (date/time/color-field, pin): default `inline-size: max-content`
(segmentos + icono + padding); `block` → 100%.
- **Contenido variable** (text/search/number/combobox/select/password/textarea):
decidir — mantener un ancho por defecto razonable (no colapsar vacío) + `block`,
o conservar 100% (convención de columna de formulario).
**Decisión de diseño** (implicación de layout de formularios): ¿alineamos TODA la
familia al modelo `block`, o solo los de contenido fijo (date/time/color/pin)?
### 5B — Nits de API (🟡)
- **`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".
### 5C — Sano (verificado, NO findings)
- **Variants**: la mayoría usa alias-arquetipo canónicos (`ControlVariant`,
`SelectionVariant`, `ChipVariant`, `MarkerVariant`) → homogéneo por construcción.
- **Defaults**: `size='md'` universal (salvo image-picker); variant por familia
consistente (inputs `'surface'`, selección `'solid'`, chips `'soft'`; field/accordion
`'outline'` = rol de wrapper, defendible).
- **Eje color/intent**: coherente y documentado — `color` (jerarquía) en todos;
`intent` (evaluativo) solo donde el componente **contiene Buttons y se lo reenvía**
(carousel/table/clipboard/dropdown-menu/alert-dialog: "flows to Button", `color`
aplica bajo `intent='neutral'`). `banner` intent-only = componente de feedback.
NO es deriva.
- **Transición**: 17 consumen `var(--duration-fast)` directo; los
`--{c}-transition-duration` encadenan a `--duration-*` desde el recipe → valor
homogéneo (solo varía la indirección). NO finding.
- **Disabled**: 27 consumen `var(--opacity-disabled)` (unificado en el sprint de
theming); los `0.X` restantes son otras opacidades (muted/overlay) ya cubiertas
en dim. 2. NO finding nuevo.
## PLAN DE REMEDIACIÓN POR LOTES
- **Lote 0 — primitivos compartidos (enabler)**:
1. `svg/`: `SvgX` (+ `SvgPlus`/`SvgMinus` para inc/dec) — animables, `pathLength`,
`data-svg`, hermanos de chevron/check/dash/dot.
2. `IconButton` (eidos): compone `Button`, `aria-label` requerido, agnóstico de
glifo, layout icon-only (`data-icon-only`). Sin morfo nuevo.
3. `Button`: quitar `iconOnly` del API público (modo interno disparado por
IconButton) + `rounded='full'` → `var(--radius-full)`.
4. Migrar los **14** `<Button iconOnly>` → `<IconButton>`. Piloto verificado:
carousel.
- **Lote A — Button-consumer** (patrón `carousel-prev-trigger`, compone Lote 0):
los 1B. calendar/range-calendar prev/next → `IconButton`+`SvgChevron`; closes
(dialog/drawer/popover/banner/toast/float-panel) → `IconButton`+`SvgX`. Borrar
el chrome `--*-control-*`/`--*-close-*` redundante del CSS.
- **Lote B — `<select>` → `<Select>`** (1A, estructural): morfo (`input`→composite)
+ soma (render `<Select.*>`) + a11y + borrar chrome de select. 6 sitios.
- **Lote C — identidad estructural** (1D opcional): tabs/pagination al look de
Button vía `data-button` (precedente toggle-group), manteniendo su sema.
- **Lote D — literales + contrato + doctrina glifos**: 2A 🔴 (button/cropper
`9999px`→`--radius-full`, grid-list, nav-menu); decidir 2B (token de focus-offset,
micro-space, escalas internas → recipe tokens); verificar/implementar
`corner-shape`; documentar la regla `svg/`-vs-`icon/` en sus READMEs.
- **Lote E — homogeneidad (dim. 5)**:
- **Focus-ring**: unificar a `outline: var(--focus-ring-width) solid
var(--focus-ring-color)` + `outline-offset` en TODO el set (migrar los ~18
box-shadow + la 3ª convención de button/link/code-block + el alias
`--color-focus-ring`); cierra el a11y de forced-colors.
- **Fondo de fields (5D)**: unificar el `surface` de la familia input a
`--color-surface-default`; `editable`/`tags-input` raised→default; arreglar
el token fantasma de `textarea` (`--color-surface` → `--color-surface-default`);
combobox vía token de recipe.
- **Segmentos date/time/color (5E)**: quitar el anillo (`box-shadow`) del segmento
activo en date/time/color-field, unificar el fondo de resalte a un token; **quitar
`border-color`+`box-shadow` de la transición del input** (elimina el flicker del
borde al incrementar por teclado).
- **Modelo de anchura (5F)**: alinear la familia field al patrón `block` — fields
de contenido fijo (date/time/color/pin) → `inline-size: max-content` + `block` para
100%. _(Pendiente decisión: ¿toda la familia o solo contenido-fijo?)_
- **Nits API (5B)**: image-picker default `md`, `switch` variant colgante,
renombrar `link` `'default'`, normalizar variants de code/code-block/kbd.
### Verificación por lote
`npm run check` 0 errores · `npx vitest run src/uix/eidos` · `generate:eidos-css`
si cambian recipes · `eidos-lint` sin `invalid` nuevos · browser (Chrome MCP):
paridad visual (hover/focus/disabled) + a11y (foco/teclado) en selects migrados.
---
## Apéndice — precedentes / plantillas a reutilizar
- **Button-consumer**: `components/carousel/carousel-prev-trigger.svelte`.
- **Identidad estructural**: toggle-group (`data-toggle` + tokens recipe).
- **Visual compartido inc/dec**: `spin-field` (number-field + css-field).
- **Select compuesto**: `components/select/index.ts`.
- **Primitivos SVG**: `components/svg/` (SvgChevron/Check/Dash/Dot).
- **Doctrina**: `soma/COMPONENT_GUIDE.md` §4 + §12.7; memoria
`feedback_button_consumer_pattern`.

@ -584,6 +584,38 @@ se vuelven custom properties, no la propiedad `font-size:`. (`words`/`palabras`/
excluidos — track activo.) El refactor a *consumir* el bundle del arquetipo (en vez de
re-declarar el mapeo) queda como follow-up; el guard es lo que **impide la deriva**.
### Campos: input 1:1 + label un paso por debajo (override 2026-06-17)
La familia de **campos** (input + label) NO sigue el arquetipo `dense`/`compact` de
arriba — su mapeo colapsado (sm=md=14, sin escalado real de xs a lg) se rechazó. La
**regla canónica de campo** es:
- **Input/control → 1:1**: `font-size-{size}` = `var(--font-size-{size})`. La fuente
sigue la escala tipográfica decidida tal cual: **12 · 14 · 16 · 18→20 · 24→28**
(xs/sm/md/lg/xl). El `md`-campo es **16px** (NO 14 — el "control compacto md=14"
vale para button/toggle, no para campos).
- **Label → un paso tipográfico por debajo del input**: **10 · 12 · 14 · 16 · 18**.
| Recipe | input | label |
|---|---|---|
| `field` (genérico) | `control-font-size-*` 1:1 | `label-font-size-*` un paso abajo → cubre todo lo que envuelve `<Field>` |
| `spin-field` (number/css), `date/time/color-field` | `font-size-*` 1:1 | date/time/color: label propio vía CSS calc; spin: label del Field |
| `search-field`, `password-field`, `select`, `editable`, `tags-input` | `font-size-*` 1:1 | label del Field |
**Label de campos segmentados** (solo date/time/color tienen `[data-X-field-label]`):
`font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm)))` — el input (`1em`
heredado) menos un paso de escala (md−sm = 2px, tokenizado). El `Field` genérico usa
tokens `label-font-size-*` por-size.
**No tocados** (ya coherentes): `pin-input` (`cell-font-size-*` ya escala), `combobox`
(sin fuente propia). **Excluidos**: `words`, `chronos`. Los no-campos (button/calendar/
badge…) conservan su arquetipo.
**Pendiente**: en `lg` el label segmentado (calc → 18) y el del `Field` (token → 16)
divergen 2px porque la `lg` del input es **fluida** (18→20). En xs/sm/md (fijos)
coinciden. Resolver dando tokens por-size a los segmentados, o quitando la `lg` fluida
del input de campo.
### Escala tipográfica ↔ escala de iconos
Son dos escalas paralelas — `--font-size-{name}` (`lib/primitives/typography.ts`)

@ -101,6 +101,9 @@
[data-color-field-label] {
color: var(--color-field-label-color);
font: inherit;
/* Label one typographic step below the input (don't inherit its size). The step
is `md − sm` (= 2px), tokenized; `1em` is the inherited input font. Scales. */
font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm)));
font-weight: var(--color-field-label-font-weight);
}
@ -176,9 +179,10 @@
}
[data-color-field-segment]:focus-visible {
background: var(--_color-field-accent-track);
color: var(--_color-field-accent-text);
box-shadow: var(--color-field-segment-focus-shadow);
/* Active highlight: shared `--field-segment-active-*` foundation tokens. */
background: var(--field-segment-active-bg);
color: var(--field-segment-active-text);
font-weight: 600;
}
[data-color-field-segment][data-invalid] {
@ -211,6 +215,8 @@
}
[data-color-field-format-select]:focus-visible {
/* The format dropdown is NOT a segment — it keeps its own focus ring, not
the `--field-segment-active-*` highlight (which is for editable segments). */
box-shadow: var(--color-field-segment-focus-shadow);
}

@ -1,6 +1,9 @@
<script lang="ts">
// Channel sliders use soma Slider parts (not eidos <Slider>), so load slider.css here first.
import '$uix/eidos/components/slider/slider.css';
// The hex/rgb value row renders color-field parts (Segment/Input/FormatSelect)
// via soma, not the eidos <ColorField> Provider — so load color-field.css here.
import '$uix/eidos/components/color-field/color-field.css';
import './color-picker.css';
import { ActiveEidos } from '$uix/eidos';
import * as ColorPicker from '$soma/components/color-picker';

@ -104,6 +104,9 @@
[data-date-field-label] {
color: var(--date-field-label-color);
font: inherit;
/* Label one typographic step below the input (don't inherit its size). The step
is `md − sm` (= 2px), tokenized; `1em` is the inherited input font. Scales. */
font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm)));
font-weight: var(--date-field-label-font-weight);
}
@ -201,18 +204,17 @@
* is correct (vs the global `:focus-visible` convention which
* suppresses focus rings on mouse-down).
*
* Visual: saturated accent element bg + accent text + 2px inset
* ring + bold weight. The ring sits inside the segment box (uses
* `inset` box-shadow) so it doesn't break the parent input's
* border alignment.
* Visual: accent element bg + accent text + bold weight. NO border/ring —
* the active segment is highlighted by background only (homogeneous across
* the date/time/color field family).
*/
[data-date-field-segment]:focus,
[data-date-field-segment]:focus-visible {
background: var(--_date-field-accent-element, var(--color-primary-element));
color: var(--_date-field-accent-text);
/* Active highlight: theme-owned via the shared `--field-segment-active-*`
foundation tokens (the bg carries its own transparency). */
background: var(--field-segment-active-bg);
color: var(--field-segment-active-text);
font-weight: 600;
--ring-inset-color: var(--_date-field-accent-border);
box-shadow: inset 0 0 0 var(--ring-inset-width) var(--ring-inset-color, currentColor);
outline: none;
}

@ -151,8 +151,12 @@
[data-date-field-input] [data-date-picker-trigger] {
flex: 0 0 auto;
inline-size: var(--date-field-segment-height);
block-size: var(--date-field-segment-height);
/* `--date-field-segment-height` is scoped to `[data-date-field]`, which the
picker doesn't render — so it's undefined here and the box collapsed to the
icon. Inline the same calc off the picker-local `--_date-field-height` (which
scales per size). `font: inherit` so the `1em` icon tracks the field font. */
inline-size: calc(var(--_date-field-height) - var(--space-2));
block-size: calc(var(--_date-field-height) - var(--space-2));
margin-inline-start: auto;
padding: 0;
border: none;
@ -160,6 +164,7 @@
background: transparent;
color: var(--date-field-segment-color);
box-shadow: none;
font: inherit;
}
[data-date-field-input] [data-date-picker-trigger]:hover {
@ -171,9 +176,8 @@
[data-date-field-input] [data-date-picker-trigger]:focus-visible {
outline: none;
border: none;
background: var(--_date-field-accent-track);
color: var(--_date-field-accent-text);
box-shadow: var(--date-field-segment-focus-shadow);
background: var(--field-segment-active-bg);
color: var(--field-segment-active-text);
}
[data-popover-content]:has([data-date-picker-calendar]) {

@ -2,6 +2,7 @@
// Renders calendar / month-grid / year-grid markup via soma parts (the view sub-components
// only `import type`), without mounting the eidos components — so load their recipes here,
// before date-picker.css so it can override them.
import '$uix/eidos/components/date-field/date-field.css';
import '$uix/eidos/components/calendar/calendar.css';
import '$uix/eidos/components/month-grid/month-grid.css';
import '$uix/eidos/components/year-grid/year-grid.css';

@ -309,9 +309,10 @@
}
[data-date-range-picker] [data-date-field-segment]:focus-visible {
background: var(--_date-field-accent-track);
color: var(--_date-field-accent-text);
box-shadow: var(--date-field-segment-focus-shadow);
/* Active highlight: shared `--field-segment-active-*` foundation tokens. */
background: var(--field-segment-active-bg);
color: var(--field-segment-active-text);
font-weight: 600;
}
[data-date-range-picker] [data-date-field-segment][data-invalid] {
@ -377,8 +378,11 @@
[data-date-range-field-input] [data-date-range-picker-trigger] {
flex: 0 0 auto;
inline-size: var(--date-field-segment-height);
block-size: var(--date-field-segment-height);
/* `--date-field-segment-height` is scoped to `[data-date-field]` (not rendered
here) → undefined, box collapsed. Use the picker-local `--_date-field-height`
(scales per size). `font: inherit` so the `1em` icon tracks the field font. */
inline-size: calc(var(--_date-field-height) - var(--space-2));
block-size: calc(var(--_date-field-height) - var(--space-2));
margin-inline-start: auto;
padding: 0;
border: none;
@ -386,6 +390,7 @@
background: transparent;
color: var(--date-field-segment-color);
box-shadow: none;
font: inherit;
}
[data-date-range-field-input] [data-date-range-picker-trigger]:hover {
@ -397,9 +402,8 @@
[data-date-range-field-input] [data-date-range-picker-trigger]:focus-visible {
outline: none;
border: none;
background: var(--_date-field-accent-track);
color: var(--_date-field-accent-text);
box-shadow: var(--date-field-segment-focus-shadow);
background: var(--field-segment-active-bg);
color: var(--field-segment-active-text);
}
[data-popover-content]:has([data-date-range-picker-calendar]) {

@ -1,4 +1,8 @@
<script lang="ts">
// Renders date-field (segments) + date-range-field parts via soma, not their
// eidos Providers — so load their recipes here, before date-range-picker.css.
import '$uix/eidos/components/date-field/date-field.css';
import '$uix/eidos/components/date-range-field/date-range-field.css';
// Renders range-calendar markup without mounting eidos <RangeCalendar> — load its recipe first.
import '$uix/eidos/components/range-calendar/range-calendar.css';
import './date-range-picker.css';

@ -14,8 +14,8 @@
`--color-focus-ring`. Per-color focus accents below use each role's `-solid`. */
--_textarea-border-hover: var(--color-border-strong);
--_textarea-border-focus: var(--color-focus-ring);
--_textarea-bg: var(--color-surface);
--_textarea-color: var(--color-content);
--_textarea-bg: var(--color-surface-default);
--_textarea-color: var(--color-content-primary);
--_textarea-placeholder: var(--color-content-muted);
--_textarea-count-color: var(--color-content-muted);
--_textarea-count-overflow: var(--color-threat-text);

@ -96,6 +96,9 @@
[data-time-field-label] {
color: var(--time-field-label-color);
font: inherit;
/* Label one typographic step below the input (don't inherit its size). The step
is `md − sm` (= 2px), tokenized; `1em` is the inherited input font. Scales. */
font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm)));
font-weight: var(--time-field-label-font-weight);
}
@ -171,9 +174,10 @@
}
[data-time-field-segment]:focus-visible {
background: var(--_time-field-accent-track);
color: var(--_time-field-accent-text);
box-shadow: var(--time-field-segment-focus-shadow);
/* Active highlight: shared `--field-segment-active-*` foundation tokens. */
background: var(--field-segment-active-bg);
color: var(--field-segment-active-text);
font-weight: 600;
}
[data-time-field-segment][data-invalid] {

@ -6,6 +6,7 @@
* the ruler chrome via the scale's snippet — no `Provider.require()`
* on the eidos side.
*/
import { ActiveEidos } from '$uix/eidos';
import * as TimePicker from '$soma/components/time-picker';
import * as Slider from '$soma/components/slider';
import { getTimePickerVisualContext } from './context.svelte';
@ -19,11 +20,17 @@
let { children, ruler, ...rest }: Props = $props();
const eidos = ActiveEidos.require();
const visual = getTimePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
// Channel label — visual caption naming the slider. The soma Slider
// already carries the same text as its `aria-label`, so the visual
// caption is `aria-hidden`. Shared `common.time.*` langref.
const label = $derived(eidos.langs.ts('#?common.time.hour|Hour'));
</script>
<div class="time-picker-slider time-picker-hour-slider">
<div data-time-picker-hour-slider class="time-picker-slider time-picker-hour-slider">
<span class="time-picker-slider-label" aria-hidden="true">{label}</span>
{#if showRuler}
<TimePicker.HourScale class="time-picker-scale">
{#snippet children({ ticks, percent, label })}

@ -1,4 +1,5 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import * as TimePicker from '$soma/components/time-picker';
import * as Slider from '$soma/components/slider';
import { getTimePickerVisualContext } from './context.svelte';
@ -18,8 +19,10 @@
const min = 0;
const max = 59;
const eidos = ActiveEidos.require();
const visual = getTimePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
const label = $derived(eidos.langs.ts('#?common.time.minute|Minute'));
function percent(v: number): string {
const t = (v - min) / (max - min);
@ -28,6 +31,7 @@
</script>
<div data-time-picker-minute-slider class="time-picker-slider time-picker-minute-slider">
<span class="time-picker-slider-label" aria-hidden="true">{label}</span>
<TimePicker.MinuteSlider {...rest}>
{#if children}
{@render children?.()}

@ -1,4 +1,5 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import * as TimePicker from '$soma/components/time-picker';
import * as Slider from '$soma/components/slider';
import { getTimePickerVisualContext } from './context.svelte';
@ -16,8 +17,10 @@
const min = 0;
const max = 59;
const eidos = ActiveEidos.require();
const visual = getTimePickerVisualContext();
const showRuler = $derived(ruler ?? visual?.getRuler() ?? true);
const label = $derived(eidos.langs.ts('#?common.time.second|Second'));
function percent(v: number): string {
const t = (v - min) / (max - min);
@ -26,6 +29,7 @@
</script>
<div data-time-picker-second-slider class="time-picker-slider time-picker-second-slider">
<span class="time-picker-slider-label" aria-hidden="true">{label}</span>
<TimePicker.SecondSlider {...rest}>
{#if children}
{@render children?.()}

@ -25,6 +25,14 @@
line-height: var(--time-field-line-height);
}
[data-time-picker][data-size='xs'] {
--_time-field-height: var(--time-field-height-xs);
--_time-field-px: var(--time-field-px-xs);
--_time-field-gap: var(--time-field-gap-xs);
--_time-field-segment-px: var(--time-field-segment-px-xs);
--_time-field-font-size: var(--time-field-font-size-xs);
}
[data-time-picker][data-size='sm'] {
--_time-field-height: var(--time-field-height-sm);
--_time-field-px: var(--time-field-px-sm);
@ -120,8 +128,11 @@
/* Embedded trigger inside the input — flush affordance like date-picker. */
[data-time-field-input] [data-time-picker-trigger] {
flex: 0 0 auto;
inline-size: var(--time-field-segment-height);
block-size: var(--time-field-segment-height);
/* `--time-field-segment-height` is scoped to `[data-time-field]` (not rendered
here) → undefined, box collapsed. Use the picker-local `--_time-field-height`
(scales per size). `font: inherit` so the `1em` icon tracks the field font. */
inline-size: calc(var(--_time-field-height) - var(--space-2));
block-size: calc(var(--_time-field-height) - var(--space-2));
margin-inline-start: auto;
padding: 0;
border: none;
@ -129,6 +140,7 @@
background: transparent;
color: var(--time-field-segment-color);
box-shadow: none;
font: inherit;
}
[data-time-field-input] [data-time-picker-trigger]:hover {
@ -140,9 +152,8 @@
[data-time-field-input] [data-time-picker-trigger]:focus-visible {
outline: none;
border: none;
background: var(--_time-field-accent-track);
color: var(--_time-field-accent-text);
box-shadow: var(--time-field-segment-focus-shadow);
background: var(--field-segment-active-bg);
color: var(--field-segment-active-text);
}
/* ── Popover content / Clock surface ─────────────────────────────────────── */

@ -1,5 +1,8 @@
<script lang="ts">
// Hour/minute sliders use soma Slider parts (not eidos <Slider>), so load slider.css here first.
// The segmented input reuses the TimeField recipe; the sliders use soma
// Slider parts (not eidos <Slider>). Load both recipes here before
// time-picker.css so it can layer on top.
import '$uix/eidos/components/time-field/time-field.css';
import '$uix/eidos/components/slider/slider.css';
import './time-picker.css';
import { ActiveEidos } from '$uix/eidos';
@ -11,7 +14,7 @@
size = 'md',
variant = 'surface',
color = 'primary',
ruler = false,
ruler = true,
value = $bindable(),
placeholder = $bindable(),
open = $bindable(false),

@ -27,6 +27,14 @@
line-height: var(--time-field-line-height);
}
[data-time-range-picker][data-size='xs'] {
--_time-field-height: var(--time-field-height-xs);
--_time-field-px: var(--time-field-px-xs);
--_time-field-gap: var(--time-field-gap-xs);
--_time-field-segment-px: var(--time-field-segment-px-xs);
--_time-field-font-size: var(--time-field-font-size-xs);
}
[data-time-range-picker][data-size='sm'] {
--_time-field-height: var(--time-field-height-sm);
--_time-field-px: var(--time-field-px-sm);
@ -122,8 +130,11 @@
/* Embedded trigger inside the input — flush affordance like time-picker. */
[data-time-range-field-input] [data-time-range-picker-trigger] {
flex: 0 0 auto;
inline-size: var(--time-field-segment-height);
block-size: var(--time-field-segment-height);
/* `--time-field-segment-height` is scoped to `[data-time-field]` (not rendered
here) → undefined, box collapsed. Use the picker-local `--_time-field-height`
(scales per size). `font: inherit` so the `1em` icon tracks the field font. */
inline-size: calc(var(--_time-field-height) - var(--space-2));
block-size: calc(var(--_time-field-height) - var(--space-2));
margin-inline-start: auto;
padding: 0;
border: none;
@ -131,6 +142,7 @@
background: transparent;
color: var(--time-field-segment-color);
box-shadow: none;
font: inherit;
}
[data-time-range-field-input] [data-time-range-picker-trigger]:hover {
@ -142,9 +154,8 @@
[data-time-range-field-input] [data-time-range-picker-trigger]:focus-visible {
outline: none;
border: none;
background: var(--_time-field-accent-track);
color: var(--_time-field-accent-text);
box-shadow: var(--time-field-segment-focus-shadow);
background: var(--field-segment-active-bg);
color: var(--field-segment-active-text);
}
/* ── Popover content / Clock surface ─────────────────────────────────────── */

@ -1,6 +1,10 @@
<script lang="ts">
// Hour/minute sliders use soma Slider parts (not eidos <Slider>), so load slider.css here first.
import '$uix/eidos/components/slider/slider.css';
// Renders time-field (segments) + time-range-field parts via soma, not their
// eidos Providers — so load their recipes here, before time-range-picker.css.
import '$uix/eidos/components/time-field/time-field.css';
import '$uix/eidos/components/time-range-field/time-range-field.css';
import './time-range-picker.css';
import { ActiveEidos } from '$uix/eidos';
import * as TimeRangePicker from '$soma/components/time-range-picker';

@ -178,6 +178,8 @@
--focus-ring-inner-width: 0px;
--focus-ring: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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-error: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color-error), 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-error);
--field-segment-active-bg: color-mix(in srgb, var(--color-primary-border) 40%, transparent);
--field-segment-active-text: var(--color-primary-text);
--container-width-sm: 448px;
--container-width-md: 688px;
--container-width-lg: 880px;
@ -1038,11 +1040,11 @@
--field-gap-xl: var(--space-3);
--field-font-family: var(--font-ui);
--field-label-gap: var(--space-1);
--field-label-font-size-xs: var(--font-size-xs);
--field-label-font-size-xs: var(--font-size-xxs);
--field-label-font-size-sm: var(--font-size-xs);
--field-label-font-size-md: var(--font-size-sm);
--field-label-font-size-lg: var(--font-size-sm);
--field-label-font-size-xl: var(--font-size-md);
--field-label-font-size-lg: var(--font-size-md);
--field-label-font-size-xl: var(--font-size-lg);
--field-label-font-weight: var(--font-weight-medium);
--field-label-line-height: var(--leading-ui);
--field-label-color: var(--color-content-primary);
@ -1066,10 +1068,10 @@
--field-control-gap-lg: var(--space-2-5);
--field-control-gap-xl: var(--space-3);
--field-control-font-size-xs: var(--font-size-xs);
--field-control-font-size-sm: var(--font-size-xs);
--field-control-font-size-md: var(--font-size-sm);
--field-control-font-size-lg: var(--font-size-sm);
--field-control-font-size-xl: var(--font-size-md);
--field-control-font-size-sm: var(--font-size-sm);
--field-control-font-size-md: var(--font-size-md);
--field-control-font-size-lg: var(--font-size-lg);
--field-control-font-size-xl: var(--font-size-xl);
--field-control-line-height: var(--leading-ui);
--field-control-radius: var(--radius-md);
--field-control-border-width: var(--border-width);
@ -1279,10 +1281,10 @@
--spin-field-gap-xl: var(--space-2-5);
--spin-field-font-family: var(--font-ui);
--spin-field-font-size-xs: var(--font-size-xs);
--spin-field-font-size-sm: var(--font-size-xs);
--spin-field-font-size-md: var(--font-size-sm);
--spin-field-font-size-lg: var(--font-size-sm);
--spin-field-font-size-xl: var(--font-size-md);
--spin-field-font-size-sm: var(--font-size-sm);
--spin-field-font-size-md: var(--font-size-md);
--spin-field-font-size-lg: var(--font-size-lg);
--spin-field-font-size-xl: var(--font-size-xl);
--spin-field-line-height: var(--leading-ui);
--spin-field-radius: var(--radius-md);
--spin-field-border-width: var(--border-width);
@ -1405,10 +1407,10 @@
--date-field-segment-px-xl: var(--space-2);
--date-field-font-family: var(--font-ui);
--date-field-font-size-xs: var(--font-size-xs);
--date-field-font-size-sm: var(--font-size-xs);
--date-field-font-size-md: var(--font-size-sm);
--date-field-font-size-lg: var(--font-size-sm);
--date-field-font-size-xl: var(--font-size-md);
--date-field-font-size-sm: var(--font-size-sm);
--date-field-font-size-md: var(--font-size-md);
--date-field-font-size-lg: var(--font-size-lg);
--date-field-font-size-xl: var(--font-size-xl);
--date-field-line-height: var(--leading-ui);
--date-field-radius: var(--radius-md);
--date-field-border-width: var(--border-width);
@ -1456,10 +1458,10 @@
--time-field-segment-px-xl: var(--space-2);
--time-field-font-family: var(--font-ui);
--time-field-font-size-xs: var(--font-size-xs);
--time-field-font-size-sm: var(--font-size-xs);
--time-field-font-size-md: var(--font-size-sm);
--time-field-font-size-lg: var(--font-size-sm);
--time-field-font-size-xl: var(--font-size-md);
--time-field-font-size-sm: var(--font-size-sm);
--time-field-font-size-md: var(--font-size-md);
--time-field-font-size-lg: var(--font-size-lg);
--time-field-font-size-xl: var(--font-size-xl);
--time-field-line-height: var(--leading-ui);
--time-field-radius: var(--radius-md);
--time-field-border-width: var(--border-width);
@ -1507,10 +1509,10 @@
--color-field-segment-px-xl: var(--space-2);
--color-field-font-family: var(--font-ui);
--color-field-font-size-xs: var(--font-size-xs);
--color-field-font-size-sm: var(--font-size-xs);
--color-field-font-size-md: var(--font-size-sm);
--color-field-font-size-lg: var(--font-size-sm);
--color-field-font-size-xl: var(--font-size-md);
--color-field-font-size-sm: var(--font-size-sm);
--color-field-font-size-md: var(--font-size-md);
--color-field-font-size-lg: var(--font-size-lg);
--color-field-font-size-xl: var(--font-size-xl);
--color-field-line-height: var(--leading-ui);
--color-field-radius: var(--radius-md);
--color-field-border-width: var(--border-width);
@ -2057,10 +2059,10 @@
--search-field-gap-xl: var(--space-3);
--search-field-font-family: var(--font-ui);
--search-field-font-size-xs: var(--font-size-xs);
--search-field-font-size-sm: var(--font-size-xs);
--search-field-font-size-md: var(--font-size-sm);
--search-field-font-size-lg: var(--font-size-sm);
--search-field-font-size-xl: var(--font-size-md);
--search-field-font-size-sm: var(--font-size-sm);
--search-field-font-size-md: var(--font-size-md);
--search-field-font-size-lg: var(--font-size-lg);
--search-field-font-size-xl: var(--font-size-xl);
--search-field-line-height: var(--leading-ui);
--search-field-radius: var(--radius-md);
--search-field-border-width: var(--border-width);
@ -2108,10 +2110,10 @@
--password-field-gap-xl: var(--space-3);
--password-field-font-family: var(--font-ui);
--password-field-font-size-xs: var(--font-size-xs);
--password-field-font-size-sm: var(--font-size-xs);
--password-field-font-size-md: var(--font-size-sm);
--password-field-font-size-lg: var(--font-size-sm);
--password-field-font-size-xl: var(--font-size-md);
--password-field-font-size-sm: var(--font-size-sm);
--password-field-font-size-md: var(--font-size-md);
--password-field-font-size-lg: var(--font-size-lg);
--password-field-font-size-xl: var(--font-size-xl);
--password-field-line-height: var(--leading-ui);
--password-field-radius: var(--radius-md);
--password-field-border-width: var(--border-width);
@ -2153,10 +2155,10 @@
--select-stack-gap: var(--space-2);
--select-font-family: var(--font-ui);
--select-font-size-xs: var(--font-size-xs);
--select-font-size-sm: var(--font-size-xs);
--select-font-size-md: var(--font-size-sm);
--select-font-size-lg: var(--font-size-md);
--select-font-size-xl: var(--font-size-lg);
--select-font-size-sm: var(--font-size-sm);
--select-font-size-md: var(--font-size-md);
--select-font-size-lg: var(--font-size-lg);
--select-font-size-xl: var(--font-size-xl);
--select-line-height: var(--leading-ui);
--select-color: var(--color-content-primary);
--select-primary-text: var(--color-primary-text);
@ -2540,10 +2542,10 @@
--editable-control-px-xl: var(--space-3-5);
--editable-font-family: var(--font-ui);
--editable-font-size-xs: var(--font-size-xs);
--editable-font-size-sm: var(--font-size-xs);
--editable-font-size-md: var(--font-size-sm);
--editable-font-size-lg: var(--font-size-md);
--editable-font-size-xl: var(--font-size-lg);
--editable-font-size-sm: var(--font-size-sm);
--editable-font-size-md: var(--font-size-md);
--editable-font-size-lg: var(--font-size-lg);
--editable-font-size-xl: var(--font-size-xl);
--editable-line-height: var(--leading-ui);
--editable-color: var(--color-content-primary);
--editable-area-min-inline-size: 12ch;
@ -2551,7 +2553,7 @@
--editable-control-border-width: var(--border-width);
--editable-control-border: var(--color-border-subtle);
--editable-control-border-hover: var(--color-border-default);
--editable-control-bg: var(--color-surface-raised);
--editable-control-bg: var(--color-surface-default);
--editable-control-shadow: var(--shadow-subtle);
--editable-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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);
--editable-placeholder-color: var(--color-content-muted);
@ -2697,10 +2699,10 @@
--tags-input-item-px-xl: var(--space-3-5);
--tags-input-font-family: var(--font-ui);
--tags-input-font-size-xs: var(--font-size-xs);
--tags-input-font-size-sm: var(--font-size-xs);
--tags-input-font-size-md: var(--font-size-sm);
--tags-input-font-size-lg: var(--font-size-md);
--tags-input-font-size-xl: var(--font-size-lg);
--tags-input-font-size-sm: var(--font-size-sm);
--tags-input-font-size-md: var(--font-size-md);
--tags-input-font-size-lg: var(--font-size-lg);
--tags-input-font-size-xl: var(--font-size-xl);
--tags-input-line-height: var(--leading-ui);
--tags-input-color: var(--color-content-primary);
--tags-input-item-gap: var(--space-1-5);
@ -2708,7 +2710,7 @@
--tags-input-control-border-width: var(--border-width);
--tags-input-control-border: var(--color-border-default);
--tags-input-control-border-hover: var(--color-border-strong);
--tags-input-control-bg: var(--color-surface-raised);
--tags-input-control-bg: var(--color-surface-default);
--tags-input-control-shadow: var(--shadow-subtle);
--tags-input-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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);
--tags-input-input-min-inline-size: 8ch;

@ -794,11 +794,11 @@ export const THEME_BASE_RECIPE_TOKENS = {
'gap-xl': 'var(--space-3)',
'font-family': 'var(--font-ui)',
'label-gap': 'var(--space-1)',
'label-font-size-xs': 'var(--font-size-xs)',
'label-font-size-xs': 'var(--font-size-xxs)',
'label-font-size-sm': 'var(--font-size-xs)',
'label-font-size-md': 'var(--font-size-sm)',
'label-font-size-lg': 'var(--font-size-sm)',
'label-font-size-xl': 'var(--font-size-md)',
'label-font-size-lg': 'var(--font-size-md)',
'label-font-size-xl': 'var(--font-size-lg)',
'label-font-weight': 'var(--font-weight-medium)',
'label-line-height': 'var(--leading-ui)',
'label-color': 'var(--color-content-primary)',
@ -822,10 +822,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-gap-lg': 'var(--space-2-5)',
'control-gap-xl': 'var(--space-3)',
'control-font-size-xs': 'var(--font-size-xs)',
'control-font-size-sm': 'var(--font-size-xs)',
'control-font-size-md': 'var(--font-size-sm)',
'control-font-size-lg': 'var(--font-size-sm)',
'control-font-size-xl': 'var(--font-size-md)',
'control-font-size-sm': 'var(--font-size-sm)',
'control-font-size-md': 'var(--font-size-md)',
'control-font-size-lg': 'var(--font-size-lg)',
'control-font-size-xl': 'var(--font-size-xl)',
'control-line-height': 'var(--leading-ui)',
'control-radius': 'var(--radius-md)',
'control-border-width': 'var(--border-width)',
@ -1062,10 +1062,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'gap-xl': 'var(--space-2-5)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'font-size-xl': 'var(--font-size-md)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
@ -1228,10 +1228,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'segment-px-xl': 'var(--space-2)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'font-size-xl': 'var(--font-size-md)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
@ -1282,10 +1282,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'segment-px-xl': 'var(--space-2)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'font-size-xl': 'var(--font-size-md)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
@ -1336,10 +1336,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'segment-px-xl': 'var(--space-2)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'font-size-xl': 'var(--font-size-md)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
@ -2092,10 +2092,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'gap-xl': 'var(--space-3)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'font-size-xl': 'var(--font-size-md)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
@ -2146,10 +2146,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'gap-xl': 'var(--space-3)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'font-size-xl': 'var(--font-size-md)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
@ -2195,10 +2195,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'stack-gap': 'var(--space-2)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-md)',
'font-size-xl': 'var(--font-size-lg)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
color: 'var(--color-content-primary)',
// Per-color forwarders consumed only by the TSC v2.2 `_accent-*`
@ -2802,10 +2802,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-px-xl': 'var(--space-3-5)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-md)',
'font-size-xl': 'var(--font-size-lg)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
color: 'var(--color-content-primary)',
'area-min-inline-size': '12ch',
@ -2813,7 +2813,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-border-width': 'var(--border-width)',
'control-border': 'var(--color-border-subtle)',
'control-border-hover': 'var(--color-border-default)',
'control-bg': 'var(--color-surface-raised)',
'control-bg': 'var(--color-surface-default)',
'control-shadow': 'var(--shadow-subtle)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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)',
@ -3023,10 +3023,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'item-px-xl': 'var(--space-3-5)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-md)',
'font-size-xl': 'var(--font-size-lg)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
'font-size-xl': 'var(--font-size-xl)',
'line-height': 'var(--leading-ui)',
color: 'var(--color-content-primary)',
'item-gap': 'var(--space-1-5)',
@ -3034,7 +3034,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-border-width': 'var(--border-width)',
'control-border': 'var(--color-border-default)',
'control-border-hover': 'var(--color-border-strong)',
'control-bg': 'var(--color-surface-raised)',
'control-bg': 'var(--color-surface-default)',
'control-shadow': 'var(--shadow-subtle)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 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)',

@ -199,6 +199,19 @@ export function renderStaticCss(options: EidosConfig): string {
)
}
// Active-segment highlight — single foundation source for the date/time/
// color field active segment. `-bg` is the full background colour (bake in
// whatever transparency you want, e.g. via `color-mix`) and `-text` is the
// segment text colour, so a theme retunes colour + opacity + text from one
// place. Defaults derive from the primary role → theme-driven.
declarations.push(
cssVar(
'field-segment-active-bg',
'color-mix(in srgb, var(--color-primary-border) 40%, transparent)'
)
)
declarations.push(cssVar('field-segment-active-text', 'var(--color-primary-text)'))
if (primitives.layout) {
appendLayoutDeclarations(declarations, primitives.layout)
}

@ -15,7 +15,7 @@ export const dateFieldMorfo = {
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('provider'),
target: v.partRef('input'),
intent: 'neutral',
sequence: 'post'
}
@ -25,7 +25,7 @@ export const dateFieldMorfo = {
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('provider'),
target: v.partRef('input'),
intent: 'neutral',
sequence: 'post'
}

@ -12,6 +12,8 @@ import type { Sema } from '../sema-map';
* descending warn cue (untilFix).
*/
// CssField is a spin-field: the bordered box IS the provider, so commit-set
// targets the provider. `onInput` is for segment-level signals (warn-invalid).
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof cssFieldMorfo>>[2]) =>
semaSelector(cssFieldMorfo, 'provider', matchers);

@ -3,19 +3,22 @@ import { dateFieldMorfo } from '$uix/morfo/components/date-field'
import { soundTuning } from '../sounds'
import type { Sema } from '../sema-map'
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof dateFieldMorfo>>[2]) =>
semaSelector(dateFieldMorfo, 'provider', matchers)
// `commit-set` / `commit-reset` target the `input` part (the field box, not the
// label-inclusive provider), so the sound cascade selects the input — matching
// where the engine stamps `data-event-*`.
const onInput = (matchers?: Parameters<typeof semaSelector<typeof dateFieldMorfo>>[2]) =>
semaSelector(dateFieldMorfo, 'input', matchers)
export const dateFieldSema: Sema = {
name: 'date-field',
cascade: [
{
selector: onProvider({ eventName: 'commit-set' }),
selector: onInput({ eventName: 'commit-set' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'tap' }
},
{
selector: onProvider({ eventName: 'commit-reset' }),
selector: onInput({ eventName: 'commit-reset' }),
sound: soundTuning('form.commit.subtle', {
contour: 'descending',
pitch: { op: 'add', value: -80 }

@ -11,6 +11,7 @@
export { accordionSema } from './accordion';
export { checkboxSema } from './checkbox';
export { chronosSema } from './chronos';
export { collapsibleSema } from './collapsible';
export { colorPickerSema } from './color-picker';
export { comboboxSema } from './combobox';

@ -11,6 +11,8 @@ import type { Sema } from '../sema-map'
* audible cue, movement stays haptic-only, and pointer release commits once.
*/
// NumberField is a spin-field: the bordered box IS the provider (the `input`
// part is the borderless text inside), so commit-set targets the provider.
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof numberFieldMorfo>>[2]) =>
semaSelector(numberFieldMorfo, 'provider', matchers)

@ -303,6 +303,12 @@ export class ColorFieldProvider {
updater: (prev: string | null) => string | null
): void {
const prev = this.segmentValues;
// Compare by the canonical hex string, not `.toString()`. `ColorValue` is a
// plain object with no custom `toString()`, so `.toString()` returns
// "[object Object]" for every color — the defined→defined increment case
// (e.g. 1e90ff → 1e9100) would never register as a change. `.hex` is always
// present and shifts with any channel (including alpha → "#rrggbbaa").
const prevHex = this.opts.value.current?.hex;
const nextVal = updater(prev[part]);
this.segmentValues = { ...prev, [part]: nextVal };
@ -316,6 +322,15 @@ export class ColorFieldProvider {
} else {
this.opts.value.current = undefined;
}
// Emit `commit-set` on value change — perceptual parity with the other
// segmented/spin fields (sound + the commit ring on the input box).
// `partRef('input')` is registered by both the visible segment group and
// the hidden form input; pass the visible field box explicitly so the
// kebab-keyed registry can't resolve the target to the hidden input.
if (this.opts.value.current?.hex !== prevHex) {
void this.runtime.trigger('commit-set', { fallbackTarget: this.fieldNode ?? undefined });
}
}
clearUpdating(): void {

@ -391,6 +391,7 @@ export class TimeFieldProvider {
part: P,
updater: (prev: TimeSegmentObj[P]) => TimeSegmentObj[P]
): void {
const previousValue = this.opts.value.current?.toString();
const prev = { ...this.segmentValues } as TimeSegmentObj;
const prevVal = prev[part];
const nextVal = updater(prevVal as TimeSegmentObj[P]);
@ -423,6 +424,17 @@ export class TimeFieldProvider {
} else {
this.setValue(undefined);
}
// Fire the `commit-set` sema event on value change — mirrors date-field so
// every incrementable field emits the same commit event (morfo declares it;
// soma fires it). `partRef('input')` is registered by BOTH the visible
// segment group and the hidden form input; the kebab-keyed runtime registry
// keeps the last one (the hidden input, whose ref can be null) → target
// error. Pass the visible field box explicitly so the commit ring (eidos
// `commit-settle`) lands on it.
const nextValue = this.opts.value.current?.toString();
if (previousValue !== nextValue) {
void this.runtime.trigger('commit-set', { fallbackTarget: this.fieldNode ?? undefined });
}
}
setValue(v: TimeValue | undefined): void {

@ -2,7 +2,7 @@
// Root page — redirect to the docs site at /uix. The legacy demo
// index that lived here has been removed along with the legacy
// per-component routes under web/routes/{name}/.
import ContactMailbox from './paloma2.svelte';
import ContactMailbox from './mail2.svelte';
</script>

@ -8,6 +8,16 @@
type DatePickerSize,
type DatePickerVariant
} from '$uix/eidos/components/date-picker';
// The embedded <TimePicker> recipe + its TimeField/Slider dependencies.
// Imported directly so the route loads them regardless of the component
// import graph.
import '$uix/eidos/components/time-field/time-field.css';
import '$uix/eidos/components/slider/slider.css';
import '$uix/eidos/components/time-picker/time-picker.css';
import {
TimePicker,
type InputSnippetProps as TimePickerInputSnippetProps
} from '$uix/eidos/components/time-picker';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { compileMorfo } from '$uix/morfo';
import { datePickerMorfo } from '@/uix/morfo/components/date-picker';
@ -17,8 +27,15 @@
import { getActiveUix } from '$active-uix';
import {
CalendarDate,
CalendarDateTime,
Time,
toCalendarDate,
toCalendarDateTime,
toTime,
type DateMatcher,
type DateValue,
type Granularity,
type TimeValue,
type WeekStartsOn
} from '$libs/days';
@ -57,6 +74,44 @@
let closeOnDateSelect = $state(false);
let mode = $state<'inline' | 'modal'>('inline');
let kind = $state<'day' | 'month' | 'year'>('day');
let withTime = $state(false);
let timeOpen = $state(false);
// Datetime is meaningful only for the day grid. The date input stays
// date-only (granularity 'day') — the composed <TimePicker> below the
// calendar owns the time portion of the value.
const granularity = $derived<Granularity | undefined>(
withTime && kind === 'day' ? 'day' : undefined
);
// Bridge the time-of-day portion of the picker value to the composed
// TimePicker (eidos already ships it): read `value` → Time, write Time back
// onto the date. The calendar preserves time on date-select, so the two
// halves stay coherent.
const timeValue = $derived<TimeValue | undefined>(
value instanceof CalendarDateTime ? toTime(value) : undefined
);
function setTimeValue(next: TimeValue | undefined): void {
if (!next) {
if (value instanceof CalendarDateTime) value = toCalendarDate(value);
return;
}
value = toCalendarDateTime(value ?? placeholder, next);
}
// Toggle time on/off, coercing the bound value + placeholder between
// CalendarDate and CalendarDateTime so the input + TimeField stay coherent.
function setWithTime(next: boolean): void {
withTime = next;
const seed = new Time(9, 30, 0);
if (next) {
if (value && !(value instanceof CalendarDateTime)) value = toCalendarDateTime(value, seed);
if (!(placeholder instanceof CalendarDateTime)) placeholder = toCalendarDateTime(placeholder, seed);
} else {
if (value instanceof CalendarDateTime) value = toCalendarDate(value);
if (placeholder instanceof CalendarDateTime) placeholder = toCalendarDate(placeholder);
}
}
// Footer composition demo toggles. Visibility is purely structural:
// the switches decide whether each part is INCLUDED in the markup,
@ -282,6 +337,7 @@
!closeOnDateSelect && ' closeOnDateSelect={false}',
mode === 'modal' && ` mode="${mode}"`,
kind !== 'day' && ` kind="${kind}"`,
granularity && ` granularity="${granularity}"`,
fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation',
preventDeselect && ' preventDeselect',
@ -319,6 +375,13 @@
' {/each}',
' {/snippet}',
' </DatePicker.Calendar>',
granularity && ' <!-- datetime: compose the existing <TimePicker> below the calendar -->',
granularity && ' <TimePicker value={timeValue} onValueChange={setTimeValue} hourCycle={24}>',
granularity && ' <TimePicker.Input>…segments…<TimePicker.Trigger /></TimePicker.Input>',
granularity && ' <TimePicker.Portal><TimePicker.Content><TimePicker.Clock>',
granularity && ' <TimePicker.HourSlider /><TimePicker.MinuteSlider />',
granularity && ' </TimePicker.Clock></TimePicker.Content></TimePicker.Portal>',
granularity && ' </TimePicker>',
' </PickerShell.Body>',
showFooter && (showClear || showCancel || showClose) && ' <DatePicker.Footer>',
showFooter && showClear && ' <DatePicker.Clear />',
@ -363,6 +426,7 @@
!closeOnDateSelect && ' closeOnDateSelect={false}',
mode === 'modal' && ` mode="${mode}"`,
kind !== 'day' && ` kind="${kind}"`,
granularity && ` granularity="${granularity}"`,
fixedWeeks && ' fixedWeeks',
pagedNavigation && ' pagedNavigation',
preventDeselect && ' preventDeselect',
@ -400,6 +464,13 @@
' {/each}',
' {/snippet}',
' </DatePicker.Calendar>',
granularity && ' <!-- datetime: compose the existing <TimePicker> below the calendar -->',
granularity && ' <TimePicker value={timeValue} onValueChange={setTimeValue} hourCycle={24}>',
granularity && ' <TimePicker.Input>…segments…<TimePicker.Trigger /></TimePicker.Input>',
granularity && ' <TimePicker.Portal><TimePicker.Content><TimePicker.Clock>',
granularity && ' <TimePicker.HourSlider /><TimePicker.MinuteSlider />',
granularity && ' </TimePicker.Clock></TimePicker.Content></TimePicker.Portal>',
granularity && ' </TimePicker>',
' </PickerShell.Body>',
showFooter && (showClear || showCancel || showClose) && ' <DatePicker.Footer>',
showFooter && showClear && ' <DatePicker.Clear />',
@ -467,6 +538,7 @@
{color}
{mode}
{kind}
{granularity}
minValue={minValue}
maxValue={maxValue}
weekStartsOn={resolvedWeekStartsOn}
@ -503,6 +575,39 @@
{@render calendarBody(args)}
{/snippet}
</DatePicker.Calendar>
{#if granularity}
<div class="date-picker-time-row">
<span class="date-picker-time-label">Time</span>
<TimePicker
value={timeValue}
onValueChange={setTimeValue}
bind:open={timeOpen}
size="sm"
{variant}
{color}
hourCycle={24}
>
<TimePicker.Input aria-label="Time">
{#snippet children({ segments }: TimePickerInputSnippetProps)}
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimePicker.Segment {part}>{value}</TimePicker.Segment>
{/each}
<TimePicker.Trigger />
{/snippet}
</TimePicker.Input>
<TimePicker.Portal>
<TimePicker.Content>
<PickerShell.Body>
<TimePicker.Clock>
<TimePicker.HourSlider />
<TimePicker.MinuteSlider />
</TimePicker.Clock>
</PickerShell.Body>
</TimePicker.Content>
</TimePicker.Portal>
</TimePicker>
</div>
{/if}
{/if}
</PickerShell.Body>
{@render pickerFooter()}
@ -673,6 +778,19 @@
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>time <span data-uix-control-hint>datetime · TimeField below the calendar</span></span
>
<span data-uix-switch
><input
type="checkbox"
checked={withTime}
disabled={kind !== 'day'}
onchange={(e) => setWithTime(e.currentTarget.checked)}
/><span data-uix-switch-label>include time</span></span
>
</label>
<label data-uix-control>
<span data-uix-control-label
>footer <span data-uix-control-hint>compose parts inside &lt;Footer&gt;</span></span
@ -1148,6 +1266,32 @@
color: var(--color-threat-text);
}
/* datetime: composed <TimePicker> below the calendar. Uses global eidos
* tokens (the docs --uix-* tokens don't resolve inside the portal). The
* top border separates it from the calendar; the footer's own top border
* separates it from the footer below. */
.date-picker-time-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding-block: var(--space-2);
border-block-start: var(--border-width, 1px) solid var(--color-border-subtle);
}
.date-picker-time-label {
flex: 0 0 auto;
color: var(--color-content-secondary);
font-size: var(--font-size-sm);
}
/* Content-sized, no shrink: keeps the segmented input from collapsing and
* overflowing to the right. */
.date-picker-time-row :global([data-time-picker]) {
inline-size: auto;
flex: 0 0 auto;
}
.month-stack {
display: grid;
grid-auto-flow: column;

@ -39,7 +39,7 @@
let disabled = $state(false);
let readonly = $state(false);
let lockSeconds = $state(false);
let ruler = $state(false);
let ruler = $state(true);
let granularity = $state<TimeGranularity>('minute');
let hourCycle = $state<HourCycle>(24);
let bounds = $state(false);

Loading…
Cancel
Save

Powered by TurnKey Linux.