refactor(eidos): share number-field + css-field visual via spin-field

NumberField and CssField are the same visual (a bordered field + input +
increment/decrement triggers + scrubber, split/stacked layouts, sizes/
variants/colors, themeable glyphs); only their value model differs. They
were two cloned recipes + CSS that drifted — a refinement to one (square
flush buttons, divider, contrast) didn't reach the other.

Unify into ONE shared source (the toggle-group structural-identity pattern):

- New eidos/components/spin-field: recipe key `spin-field` (`--spin-field-*`)
  + spin-field.css with all the stepper-field rules, selecting `[data-spin-
  field*]`. Loaded via the foundation @import in index.css.
- number-field + css-field morfos declare structural identity (`data-spin-
  field*` presence attrs on each part). The Provider emits them via syncAttrs;
  the sub-parts emit them in their soma `props` getter (number-field's soma
  hardcodes sub-part attrs rather than syncing the morfo).
- Removed the `number-field` / `css-field` recipe keys; their CSS files are
  now stubs. A theme tints one component by scoping `[data-number-field] {
  --spin-field-… }`.
- css-field thereby adopts number-field's refined steppers (square, flush,
  divider) — the drift fix the user asked for, now structural (no clone).

Verified bit-for-bit in browser: number-field identical to baseline (split
flush, stacked symmetric xs..xl, RTL, glyph token/children override);
css-field now square/flush/divider. eidos-lint invalid 0; recipe contract
passes (no orphans, loads-once); check + morfo:check clean for these.

Docs: THEMING sections 33 (glyph tokens now `--spin-field-*`) + 34 (the
shared layer); number-field / css-field READMEs.

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

@ -61,7 +61,8 @@
30. [Forma (shape) — continuidad + familias + anidado](#30-forma-shape--continuidad--familias--anidado--eventful-2026-06-05)
31. [Estructura (espacio · densidad · escala)](#31-estructura-espacio--densidad--escala--el-espacio-como-ritmo-2026-06-05)
32. [Focus ring — modelo de dos anillos parametrizado](#32-focus-ring--modelo-de-dos-anillos-parametrizado-2026-06-11)
33. [Glifos de stepper themeables (`number-field` / `css-field`)](#33-glifos-de-stepper-themeables-number-field--css-field--2026-06-11)
33. [Glifos de stepper themeables (`spin-field`)](#33-glifos-de-stepper-themeables-spin-field--2026-06-11)
34. [`spin-field` — visual compartido del stepper-field](#34-spin-field--visual-compartido-del-stepper-field-number-field--css-field--2026-06-11)
---
@ -2464,33 +2465,65 @@ consumir `accent-border` en el anillo del campo. **Hoy** ambos usan el `--focus-
genérico (consistente con el resto), así que `data-color` no tiñe su foco — decisión
deliberada de la unificación.
## 33. Glifos de stepper themeables (`number-field` / `css-field`) — 2026-06-11
## 33. Glifos de stepper themeables (`spin-field`) — 2026-06-11
Los botones increment/decrement de `number-field` y `css-field` pintan su glifo desde
un **token**, no desde markup obligatorio. Un trigger sin children renderiza el glifo
por defecto vía `:empty::before`; pasar children lo overridea por instancia. El glifo es
**decorativo** — el botón se etiqueta con su `aria-label` (morfo), así que `content` en
un pseudo-elemento es seguro (mismo patrón que `--date-range-field-separator-glyph`).
Los botones increment/decrement pintan su glifo desde un **token**, no desde markup
obligatorio. Un trigger sin children renderiza el glifo por defecto vía `:empty::before`;
pasar children lo overridea por instancia. El glifo es **decorativo** — el botón se
etiqueta con su `aria-label` (morfo), así que `content` en un pseudo-elemento es seguro
(mismo patrón que `--date-range-field-separator-glyph`).
Cuatro tokens por componente, dos por layout:
Cuatro tokens, dos por layout (viven en el recipe **compartido** `spin-field` — ver §34):
| Token | Default | Layout |
| --- | --- | --- |
| `--{c}-control-increment-glyph` | `'+'` | split |
| `--{c}-control-decrement-glyph` | `'−'` (`\2212`) | split |
| `--{c}-control-increment-glyph-stacked` | `'▲'` (`\25B2`) | stacked |
| `--{c}-control-decrement-glyph-stacked` | `'▼'` (`\25BC`) | stacked |
(`{c}` = `number-field` | `css-field`.) El CSS resuelve una variable interna
`--_{c}-increment-glyph` que apunta al token split por defecto y se re-apunta al
hermano `-stacked` bajo `[data-steppers='stacked']`, de modo que una sola regla
`content` sirve ambos layouts. Un tema retinta/reforma overrideando cualquiera de los
cuatro; el **color** del glifo ya viaja por `--{c}-control-color*` (no se duplica aquí).
| `--spin-field-control-increment-glyph` | `'+'` | split |
| `--spin-field-control-decrement-glyph` | `'−'` (`\2212`) | split |
| `--spin-field-control-increment-glyph-stacked` | `'▲'` (`\25B2`) | stacked |
| `--spin-field-control-decrement-glyph-stacked` | `'▼'` (`\25BC`) | stacked |
El CSS resuelve una variable interna `--_spin-field-increment-glyph` que apunta al token
split por defecto y se re-apunta al hermano `-stacked` bajo `[data-steppers='stacked']`,
de modo que una sola regla `content` sirve ambos layouts. Un tema retinta/reforma
overrideando cualquiera de los cuatro (globalmente o scoped por componente con
`[data-number-field] { --spin-field-control-… }`); el **color** del glifo ya viaja por
`--spin-field-control-color*` (no se duplica aquí).
Por qué cuatro y no dos: split usa el par horizontal `+`/`−`; la columna stacked usa
flechas verticales `▲`/`▼`. Un único par no puede tener ambos defaults a la vez, y forzar
`▲`/`▼` en split (o `+`/`−` en stacked) rompe la convención. Cada par es independiente.
## 34. `spin-field` — visual compartido del stepper-field (`number-field` / `css-field`) — 2026-06-11
`number-field` y `css-field` son **el mismo visual** (campo con borde + input + botones
increment/decrement + scrubber, layouts split/stacked, sizes/variants/colors, glifos);
solo difieren en el *modelo de valor* (soma: número vs valor CSS). Tener dos recipes +
dos CSS clonados causaba **drift**: refinar uno (botones cuadrados, flush, divisor) dejaba
el otro con el look viejo. La respuesta canónica no es duplicar — es **compartir
estructuralmente**, el mismo patrón que `toggle-group` reusa `toggle`.
**Cómo**:
- **Recipe único** `spin-field` en `recipes/base.ts` → tokens `--spin-field-*` (geometría,
superficie, control, glifos). NO hay `--number-field-*` / `--css-field-*`.
- **CSS único** `components/spin-field/spin-field.css` con todas las reglas del
stepper-field, seleccionando `[data-spin-field*]`. Cargado por el `@import` de foundation
en `index.css` (no tiene `.svelte` propio que lo auto-importe).
- **Identidad estructural** en los morfos de `number-field` y `css-field`: cada part declara
`data-spin-field` / `-input` / `-increment-trigger` / `-decrement-trigger` / `-scrubber`
(presence attrs). El Provider los emite vía `syncAttrs`; los sub-parts (cuyo soma
hardcodea sus attrs) los emiten en su getter `props`. `number-field.css` y `css-field.css`
quedan como stubs.
**Theming por componente**: aunque los tokens son compartidos, un tema puede tintar solo
uno scopeando el token al `data-` del componente — `[data-number-field] { --spin-field-bg:
… }` lo hereda el stepper porque vive dentro de ese elemento. El default es compartido.
**Resultado**: una sola fuente del visual del stepper-field. Un fix se aplica a los dos (y a
cualquier futuro spin-field) sin posibilidad de drift. `date`/`time`/`color-field` son
*segmentados* (sin steppers) — comparten solo la *superficie* del campo, lo que sería un
refactor aparte.
---
**Última revisión**: 2026-06-11. Si algo en este doc no coincide con

@ -29,23 +29,36 @@ compact arrow height tracks `size` (each arrow fills half the field height).
All Soma `Provider` props pass through (`value`, `allowedUnits`, `keywords`, `validate`, `min`/`max`, `step`, …).
## Recipe
## Recipe — shared `spin-field` visual
Tokens live under the `css-field` recipe key in `src/uix/eidos/lib/recipes/base.ts`
(`--css-field-*`), re-mapped to internal `--_css-field-*` for the size/color cascade in
`css-field.css`. The control row reuses the same control-height / spacing tokens as
`number-field`. `[data-invalid]` tints the border with `--color-risk-border`;
`[data-focused]` / `[data-scrubbing]` raise the focus ring.
CssField has no visual of its own: it IS a `spin-field`. Visually it is identical to
NumberField (bordered field + input + increment/decrement triggers + scrubber,
split/stacked layouts, sizes/variants/colors, themeable glyphs); only the value model
differs (soma: CSS value vs number). So both reuse ONE source instead of cloning:
- Tokens live under the shared `spin-field` recipe key (`--spin-field-*`) in
`src/uix/eidos/lib/recipes/base.ts`.
- Rules live in `eidos/components/spin-field/spin-field.css` (loaded via the foundation
`@import` in `index.css`), selecting `[data-spin-field*]`.
- The `css-field` morfo declares structural identity (`data-spin-field*` on its parts), so
those rules style it; `css-field.css` is a stub.
`[data-invalid]` tints the border with `--color-risk-border`; `[data-focused]` /
`[data-scrubbing]` raise the focus ring. A theme tints just CssField by scoping the token
to its data-attr: `[data-css-field] { --spin-field-bg: … }`. See THEMING §34.
**Themeable stepper glyphs** — a trigger with no children renders a default glyph
from a token via `:empty::before` (the glyph is decorative; the button is labeled by
its aria-label). Override per theme: `--css-field-control-increment-glyph` /
its aria-label). Override per theme: `--spin-field-control-increment-glyph` /
`-decrement-glyph` for the split layout, `…-glyph-stacked` siblings for the compact
column. Passing children to a trigger overrides the token for that instance.
## Baseline
No Air baseline — new component. Recipe cloned from `number-field`'s with token renames.
No Air baseline — new component. Originally cloned `number-field`'s recipe + CSS with token
renames; that clone drifted (a refinement to one didn't reach the other), so both were
unified into the shared `spin-field` visual (see Recipe above + THEMING §34). No clone
remains.
## Comparativa externa

@ -1,253 +1,7 @@
[data-css-field] {
--_css-field-height: var(--css-field-height-md);
--_css-field-px: var(--css-field-px-md);
--_css-field-gap: var(--css-field-gap-md);
--_css-field-font-size: var(--css-field-font-size-md);
display: inline-flex;
align-items: center;
gap: var(--_css-field-gap);
inline-size: 100%;
min-inline-size: 0;
block-size: var(--_css-field-height);
padding-inline: var(--_css-field-px);
border: var(--css-field-border-width) solid var(--css-field-border);
border-radius: var(--css-field-radius);
background: var(--css-field-bg);
color: var(--css-field-color);
font-family: var(--css-field-font-family);
font-size: var(--_css-field-font-size);
line-height: var(--css-field-line-height);
transition:
background var(--css-field-transition-duration) var(--css-field-transition-ease),
border-color var(--css-field-transition-duration) var(--css-field-transition-ease),
box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease);
}
[data-css-field][data-size='xs'] {
--_css-field-height: var(--css-field-height-xs);
--_css-field-px: var(--css-field-px-xs);
--_css-field-gap: var(--css-field-gap-xs);
--_css-field-font-size: var(--css-field-font-size-xs);
}
[data-css-field][data-size='sm'] {
--_css-field-height: var(--css-field-height-sm);
--_css-field-px: var(--css-field-px-sm);
--_css-field-gap: var(--css-field-gap-sm);
--_css-field-font-size: var(--css-field-font-size-sm);
}
[data-css-field][data-size='lg'] {
--_css-field-height: var(--css-field-height-lg);
--_css-field-px: var(--css-field-px-lg);
--_css-field-gap: var(--css-field-gap-lg);
--_css-field-font-size: var(--css-field-font-size-lg);
}
[data-css-field][data-size='xl'] {
--_css-field-height: var(--css-field-height-xl);
--_css-field-px: var(--css-field-px-xl);
--_css-field-gap: var(--css-field-gap-xl);
--_css-field-font-size: var(--css-field-font-size-xl);
}
[data-css-field][data-variant='outline'] {
background: transparent;
}
[data-css-field][data-variant='ghost'] {
border-color: transparent;
background: transparent;
padding-inline: 0;
}
[data-css-field][data-focused],
[data-css-field][data-scrubbing] {
border-color: var(--css-field-border-focus);
box-shadow: var(--css-field-focus-shadow);
}
[data-css-field][data-invalid] {
border-color: var(--css-field-border-invalid);
}
[data-css-field][data-disabled] {
opacity: var(--css-field-disabled-opacity);
}
[data-css-field-input] {
flex: 1 1 auto;
min-inline-size: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
outline: none;
transition: box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease);
}
[data-css-field-input][data-disabled] {
cursor: not-allowed;
}
/* Focus is shown once, by the root's canonical two-ring focus-shadow
(`[data-css-field][data-focused]`). No separate ring on the input — that
stacked a second (inner) frame inside the root ring. */
[data-css-field-increment-trigger],
[data-css-field-decrement-trigger],
[data-css-field-scrubber] {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
/* Stepper buttons and scrubber inherit the Provider's resolved height so
they always match the input row, regardless of size token. */
inline-size: var(--_css-field-height);
block-size: var(--_css-field-height);
min-inline-size: var(--_css-field-height);
border: var(--css-field-control-border-width) solid var(--css-field-control-border);
border-radius: var(--css-field-control-radius);
background: var(--css-field-control-bg);
color: var(--css-field-control-color);
font: inherit;
outline: none;
transition:
background var(--css-field-transition-duration) var(--css-field-transition-ease),
border-color var(--css-field-transition-duration) var(--css-field-transition-ease),
color var(--css-field-transition-duration) var(--css-field-transition-ease),
box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease);
}
[data-css-field-increment-trigger],
[data-css-field-decrement-trigger] {
cursor: pointer;
}
[data-css-field-scrubber] {
cursor: ew-resize;
}
[data-css-field-increment-trigger]:hover:not([data-disabled]),
[data-css-field-decrement-trigger]:hover:not([data-disabled]),
[data-css-field-scrubber]:hover:not([data-disabled]) {
border-color: var(--css-field-control-border-hover);
background: var(--css-field-control-bg-hover);
color: var(--css-field-control-color-hover);
}
[data-css-field-increment-trigger]:focus-visible,
[data-css-field-decrement-trigger]:focus-visible,
[data-css-field-scrubber]:focus-visible {
box-shadow: var(--css-field-control-focus-shadow);
}
[data-css-field-increment-trigger][data-disabled],
[data-css-field-decrement-trigger][data-disabled],
[data-css-field-scrubber][data-disabled] {
cursor: default;
opacity: var(--css-field-control-disabled-opacity);
}
[data-css-field-scrubber][data-scrubbing] {
border-color: var(--css-field-control-border-active);
background: var(--css-field-control-bg-active);
color: var(--css-field-control-color-active);
}
/* ── Default stepper glyphs (themeable) ───────────────────────────────────────
A trigger with NO children renders the glyph from a token via `:empty::before`;
passing children overrides it. The glyph is decorative — the button is labeled
by its aria-label. The internal var picks the split (+/−) or stacked (▲/▼) token
so a single `content` rule serves both layouts; a theme overrides any token. */
[data-css-field-increment-trigger] {
--_css-field-increment-glyph: var(--css-field-control-increment-glyph);
}
[data-css-field-decrement-trigger] {
--_css-field-decrement-glyph: var(--css-field-control-decrement-glyph);
}
[data-css-field][data-steppers='stacked'] [data-css-field-increment-trigger] {
--_css-field-increment-glyph: var(--css-field-control-increment-glyph-stacked);
}
[data-css-field][data-steppers='stacked'] [data-css-field-decrement-trigger] {
--_css-field-decrement-glyph: var(--css-field-control-decrement-glyph-stacked);
}
[data-css-field-increment-trigger]:empty::before {
content: var(--_css-field-increment-glyph);
}
[data-css-field-decrement-trigger]:empty::before {
content: var(--_css-field-decrement-glyph);
}
/* ── Stacked steppers — compact column on the inline-end side ─────────────────
A 2×2 grid: the input fills the inline-start, the increment (top) and decrement
(bottom) share one narrow column on the inline-end. The grid mirrors under RTL.
Self-contained — the default `split` layout is left untouched. */
[data-css-field][data-steppers='stacked'] {
display: grid;
grid-template-columns: 1fr auto;
/* `minmax(0, …)` lets each row be EXACTLY half the field height. Plain `1fr`
has an implicit `min-content` floor, so a glyph taller than half the field
(small sizes) would grow its row and push the other past the clipped edge. */
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'input increment'
'input decrement';
/* The base rule sets `align-items: center` for its inline-flex layout; once we
switch to grid that would center each arrow at its content height instead of
filling its row. Restore the grid default so each arrow stretches to half the
field height — making the stepper height a function of `size`. */
align-items: stretch;
/* The stepper column reaches the edge itself: drop the inline-flex gap + the
field's inline padding (the input carries it), and clip square corners to the
field's rounded border. */
gap: 0;
padding-inline: 0;
overflow: hidden;
}
[data-css-field][data-steppers='stacked'] [data-css-field-input] {
grid-area: input;
padding-inline: var(--_css-field-px);
}
[data-css-field][data-steppers='stacked'] [data-css-field-increment-trigger],
[data-css-field][data-steppers='stacked'] [data-css-field-decrement-trigger] {
inline-size: calc(var(--_css-field-height) * 0.62);
min-inline-size: calc(var(--_css-field-height) * 0.62);
/* each arrow takes half the field height (the grid row), not the full height */
block-size: auto;
/* square in the compact column, clipped to the field's rounded corner */
border-radius: 0;
/* vertical divider toward the input */
border-inline-start: var(--css-field-control-border-width) solid var(--css-field-border);
/* Glyph scales with the field height (≈ a third of it), so it stays inside the
half-height cell at every size — including `xs`, where a fixed `0.9em` overflowed. */
font-size: calc(var(--_css-field-height) * 0.34);
line-height: 1;
}
[data-css-field][data-steppers='stacked'] [data-css-field-increment-trigger] {
grid-area: increment;
}
[data-css-field][data-steppers='stacked'] [data-css-field-decrement-trigger] {
grid-area: decrement;
/* horizontal divider between the two arrows */
border-block-start: var(--css-field-control-border-width) solid var(--css-field-border);
}
/* No room for the pointer scrubber in the stacked layout. */
[data-css-field][data-steppers='stacked'] [data-css-field-scrubber] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
[data-css-field],
[data-css-field-increment-trigger],
[data-css-field-decrement-trigger],
[data-css-field-scrubber] {
transition: none;
}
}
/* CssField has no visual of its own: it IS a spin-field. Its morfo declares
structural identity (`data-spin-field*`), so the shared stepper-field visual in
`../spin-field/spin-field.css` (loaded via the foundation @import in index.css)
styles it entirely — field surface, split/stacked steppers, themeable glyphs,
sizes, variants. This file stays as the component's self-import anchor (and as
the place for any future css-field-only chrome). A theme tints just this
component by scoping `[data-css-field] { --spin-field-… }`. */

@ -123,10 +123,15 @@ documentan como gap Soma futuro.
**Glifos themeables** — un trigger sin children pinta el glifo por defecto desde un
token vía `:empty::before` (decorativo; el botón se etiqueta con su aria-label).
Override por tema: `--number-field-control-increment-glyph` / `-decrement-glyph`
Override por tema: `--spin-field-control-increment-glyph` / `-decrement-glyph`
(split, `+`/`−`) y los hermanos `…-glyph-stacked` (`▲`/`▼`). Pasar children a un
trigger overridea el token para esa instancia.
**Visual compartido** — NumberField no tiene CSS propio: ES un `spin-field`. Su morfo
declara identidad estructural (`data-spin-field*`) y reúsa el visual compartido en
`eidos/components/spin-field` (recipe `--spin-field-*` + `spin-field.css`), igual que
CssField. Un fix visual se aplica a ambos sin drift. Ver THEMING §34.
## Gaps
| Gap | Disposición | Detalle |

@ -1,286 +1,7 @@
[data-number-field] {
--_number-field-height: var(--number-field-height-md);
--_number-field-px: var(--number-field-px-md);
--_number-field-gap: var(--number-field-gap-md);
--_number-field-font-size: var(--number-field-font-size-md);
display: inline-flex;
align-items: center;
inline-size: 100%;
min-inline-size: 0;
block-size: var(--_number-field-height);
padding-inline: var(--_number-field-px);
border: var(--number-field-border-width) solid var(--number-field-border);
border-radius: var(--number-field-radius);
background: var(--number-field-bg);
/* The edge steppers break OUT of this inline padding (negative margin) so they
sit FLUSH against the field border; `overflow` then clips them to the
rounded corners. The padding still spaces the input from the field ends. */
overflow: hidden;
color: var(--number-field-color);
font-family: var(--number-field-font-family);
font-size: var(--_number-field-font-size);
line-height: var(--number-field-line-height);
transition:
background var(--number-field-transition-duration) var(--number-field-transition-ease),
border-color var(--number-field-transition-duration) var(--number-field-transition-ease),
box-shadow var(--number-field-transition-duration) var(--number-field-transition-ease);
}
[data-number-field][data-size='xs'] {
--_number-field-height: var(--number-field-height-xs);
--_number-field-px: var(--number-field-px-xs);
--_number-field-gap: var(--number-field-gap-xs);
--_number-field-font-size: var(--number-field-font-size-xs);
}
[data-number-field][data-size='sm'] {
--_number-field-height: var(--number-field-height-sm);
--_number-field-px: var(--number-field-px-sm);
--_number-field-gap: var(--number-field-gap-sm);
--_number-field-font-size: var(--number-field-font-size-sm);
}
[data-number-field][data-size='lg'] {
--_number-field-height: var(--number-field-height-lg);
--_number-field-px: var(--number-field-px-lg);
--_number-field-gap: var(--number-field-gap-lg);
--_number-field-font-size: var(--number-field-font-size-lg);
}
[data-number-field][data-size='xl'] {
--_number-field-height: var(--number-field-height-xl);
--_number-field-px: var(--number-field-px-xl);
--_number-field-gap: var(--number-field-gap-xl);
--_number-field-font-size: var(--number-field-font-size-xl);
}
[data-number-field][data-variant='outline'] {
background: transparent;
}
[data-number-field][data-variant='ghost'] {
border-color: transparent;
background: transparent;
padding-inline: 0;
}
[data-number-field][data-focused],
[data-number-field][data-scrubbing] {
border-color: var(--number-field-border-focus);
box-shadow: var(--number-field-focus-shadow);
}
[data-number-field][data-invalid] {
border-color: var(--number-field-border-invalid);
}
[data-number-field][data-disabled] {
opacity: var(--number-field-disabled-opacity);
}
[data-number-field-input] {
flex: 1 1 auto;
order: 1;
min-inline-size: 0;
/* No gap on the field — the steppers butt right up against the input via their
divider. This padding (the former gap value) keeps the text off the divider. */
padding-inline: var(--_number-field-gap);
border: 0;
background: transparent;
color: inherit;
font: inherit;
outline: none;
transition: box-shadow var(--number-field-transition-duration)
var(--number-field-transition-ease);
}
/* Split layout (default) — decrement flush at the inline-start edge, increment
flush at the inline-end edge, scrubber tucked just inside the increment.
`order` makes the arrangement independent of composition order and flips under
RTL; the negative margin on the two end triggers cancels the field's inline
padding so they reach the border; the divider sits on each trigger's INNER
side (decrement → end, increment → start), flipping under RTL. */
[data-number-field]:not([data-steppers='stacked']) [data-number-field-decrement-trigger] {
order: 0;
margin-inline-start: calc(-1 * var(--_number-field-px));
border-inline-end: var(--number-field-control-border-width) solid
var(--number-field-control-divider);
}
[data-number-field]:not([data-steppers='stacked']) [data-number-field-scrubber] {
order: 2;
}
[data-number-field]:not([data-steppers='stacked']) [data-number-field-increment-trigger] {
order: 3;
margin-inline-end: calc(-1 * var(--_number-field-px));
border-inline-start: var(--number-field-control-border-width) solid
var(--number-field-control-divider);
}
[data-number-field-input][data-disabled] {
cursor: not-allowed;
}
/* Focus is shown once, by the root's canonical two-ring focus-shadow
(`[data-number-field][data-focused]`). No separate ring on the input — that
stacked a second (inner) frame inside the root ring. */
[data-number-field-increment-trigger],
[data-number-field-decrement-trigger],
[data-number-field-scrubber] {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
/* Stepper buttons and scrubber inherit the Provider's resolved height so
they always match the input row, regardless of size token. */
inline-size: var(--_number-field-height);
block-size: var(--_number-field-height);
min-inline-size: var(--_number-field-height);
border: var(--number-field-control-border-width) solid var(--number-field-control-border);
border-radius: var(--number-field-control-radius);
background: var(--number-field-control-bg);
color: var(--number-field-control-color);
font: inherit;
outline: none;
transition:
background var(--number-field-transition-duration) var(--number-field-transition-ease),
border-color var(--number-field-transition-duration) var(--number-field-transition-ease),
color var(--number-field-transition-duration) var(--number-field-transition-ease),
box-shadow var(--number-field-transition-duration) var(--number-field-transition-ease);
}
[data-number-field-increment-trigger],
[data-number-field-decrement-trigger] {
cursor: pointer;
}
[data-number-field-scrubber] {
cursor: ew-resize;
}
[data-number-field-increment-trigger]:hover:not([data-disabled]),
[data-number-field-decrement-trigger]:hover:not([data-disabled]),
[data-number-field-scrubber]:hover:not([data-disabled]) {
border-color: var(--number-field-control-border-hover);
background: var(--number-field-control-bg-hover);
color: var(--number-field-control-color-hover);
}
[data-number-field-increment-trigger]:focus-visible,
[data-number-field-decrement-trigger]:focus-visible,
[data-number-field-scrubber]:focus-visible {
background: var(--number-field-control-bg-focus);
color: var(--number-field-control-color-hover);
box-shadow: var(--number-field-control-focus-shadow);
}
[data-number-field-increment-trigger][data-disabled],
[data-number-field-decrement-trigger][data-disabled],
[data-number-field-scrubber][data-disabled] {
cursor: default;
opacity: var(--number-field-control-disabled-opacity);
}
[data-number-field-scrubber][data-scrubbing] {
border-color: var(--number-field-control-border-active);
background: var(--number-field-control-bg-active);
color: var(--number-field-control-color-active);
}
/* ── Default stepper glyphs (themeable) ───────────────────────────────────────
A trigger with NO children renders the glyph from a token via `:empty::before`;
passing children overrides it. The glyph is decorative — the button is labeled
by its aria-label. The internal var picks the split (+/−) or stacked (▲/▼) token
so a single `content` rule serves both layouts; a theme overrides any token. */
[data-number-field-increment-trigger] {
--_number-field-increment-glyph: var(--number-field-control-increment-glyph);
}
[data-number-field-decrement-trigger] {
--_number-field-decrement-glyph: var(--number-field-control-decrement-glyph);
}
[data-number-field][data-steppers='stacked'] [data-number-field-increment-trigger] {
--_number-field-increment-glyph: var(--number-field-control-increment-glyph-stacked);
}
[data-number-field][data-steppers='stacked'] [data-number-field-decrement-trigger] {
--_number-field-decrement-glyph: var(--number-field-control-decrement-glyph-stacked);
}
[data-number-field-increment-trigger]:empty::before {
content: var(--_number-field-increment-glyph);
}
[data-number-field-decrement-trigger]:empty::before {
content: var(--_number-field-decrement-glyph);
}
/* ── Stacked steppers — compact column on the inline-end side ─────────────────
A 2×2 grid: the input fills the inline-start, the increment (top) and
decrement (bottom) share one narrow column on the inline-end. The grid mirrors
under RTL, so the column moves to the inline-start (left) automatically. */
[data-number-field][data-steppers='stacked'] {
display: grid;
grid-template-columns: 1fr auto;
/* `minmax(0, …)` lets each row be EXACTLY half the field height. Plain `1fr`
has an implicit `min-content` floor, so a glyph taller than half the field
(small sizes) would grow its row and push the other past the clipped edge —
the bug where decrement looked cut off and the two arrows were unequal. */
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'input increment'
'input decrement';
/* The base field rule sets `align-items: center` for its inline-flex layout;
once we switch to grid that would center each arrow at its content height
instead of filling its row. Restore the grid default so each arrow stretches
to half the field height — making the stepper height a function of `size`. */
align-items: stretch;
/* The stepper column reaches the edge itself, so the field drops its inline
padding; the input carries it instead. */
padding-inline: 0;
}
[data-number-field][data-steppers='stacked'] [data-number-field-input] {
grid-area: input;
padding-inline: var(--_number-field-px);
}
[data-number-field][data-steppers='stacked'] [data-number-field-increment-trigger],
[data-number-field][data-steppers='stacked'] [data-number-field-decrement-trigger] {
inline-size: calc(var(--_number-field-height) * 0.62);
min-inline-size: calc(var(--_number-field-height) * 0.62);
/* each arrow takes half the field height (the grid row), not the full height */
block-size: auto;
/* the split layout's negative edge margins don't apply here */
margin: 0;
/* vertical divider toward the input */
border-inline-start: var(--number-field-control-border-width) solid
var(--number-field-border);
/* Glyph scales with the field height (≈ a third of it), so it stays inside the
half-height cell at every size — including `xs`, where a fixed `0.9em` was too
tall and overflowed. Scales up cleanly through `xl`. */
font-size: calc(var(--_number-field-height) * 0.34);
line-height: 1;
}
[data-number-field][data-steppers='stacked'] [data-number-field-increment-trigger] {
grid-area: increment;
}
[data-number-field][data-steppers='stacked'] [data-number-field-decrement-trigger] {
grid-area: decrement;
/* horizontal divider between the two arrows */
border-block-start: var(--number-field-control-border-width) solid
var(--number-field-border);
}
/* No room for the pointer scrubber in the stacked layout. */
[data-number-field][data-steppers='stacked'] [data-number-field-scrubber] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
[data-number-field],
[data-number-field-increment-trigger],
[data-number-field-decrement-trigger],
[data-number-field-scrubber] {
transition: none;
}
}
/* NumberField has no visual of its own: it IS a spin-field. Its morfo declares
structural identity (`data-spin-field*`), so the shared stepper-field visual in
`../spin-field/spin-field.css` (loaded via the foundation @import in index.css)
styles it entirely — field surface, split/stacked steppers, themeable glyphs,
sizes, variants. This file stays as the component's self-import anchor (and as
the place for any future number-field-only chrome). A theme tints just this
component by scoping `[data-number-field] { --spin-field-… }`. */

@ -19,6 +19,7 @@
import { Select } from '$uix/eidos/components/select';
import { Slider } from '$uix/eidos/components/slider';
import { CssField } from '$uix/eidos/components/css-field';
import { NumberField } from '$uix/eidos/components/number-field';
import { Checkbox } from '$uix/eidos/components/checkbox';
import { Button } from '$uix/eidos/components/button';
import { Strikethrough } from '$uix/eidos/components/icon';
@ -116,13 +117,23 @@
// Active preset / slider values, derived from the stored prop. A chip is active
// only while the stored value EQUALS its scale step (string compare).
// Active preset = the stored value, or the THEME DEFAULT step while unset (so the
// theme's resting preset shows selected, never an empty toggle).
const leadingActive = $derived(value.lineHeight == null ? LEADING_DEFAULT : String(value.lineHeight));
const trackingActive = $derived(value.letterSpacing ?? TRACKING_DEFAULT);
// Preset Select selection: the matching theme step (or the default while unset);
// empty when a MANUAL value doesn't land on a step (the slider/input own it then).
const leadingSel = $derived.by(() => {
const v = value.lineHeight == null ? LEADING_DEFAULT : String(value.lineHeight);
return LEADING.some((o) => o.value === v) ? [v] : [];
});
const trackingSel = $derived.by(() => {
const v = value.letterSpacing ?? TRACKING_DEFAULT;
return TRACKING.some((o) => o.value === v) ? [v] : [];
});
// Tamaño shown = the override, else the block's default (the H level's own size).
const sizeEffective = $derived(value.fontSize ?? defaultFontSize);
// Manual slider values (px / unitless / ch — same unit as each prop's input).
const leadingSlider = $derived(value.lineHeight ?? Number(LEADING_DEFAULT));
const trackingSlider = $derived(pxOf(value.letterSpacing));
const measureSlider = $derived(chOf(value.maxWidth));
const allFonts = $derived(fonts.flatMap((g) => g.fonts));
const fontValue = $derived(value.fontFamily ? [value.fontFamily] : []);
const weightValue = $derived(value.fontWeight != null ? [String(value.fontWeight)] : []);
const wrapValue = $derived(value.textWrap ? [value.textWrap] : []);
@ -156,8 +167,93 @@
function setLeading(n: number | undefined) {
onPatch({ lineHeight: n && n > 0 ? n : undefined });
}
// Tracking / Medida manual sliders write px / ch (the CssField owns precise units).
function pxOf(s: string | undefined): number {
const m = s ? /^(-?\d*\.?\d+)px$/.exec(s) : null;
return m ? parseFloat(m[1]) : 0;
}
function chOf(s: string | undefined): number {
const m = s ? /^(\d*\.?\d+)ch$/.exec(s) : null;
return m ? parseFloat(m[1]) : 66;
}
function setLetterPx(n: number | undefined) {
onPatch({ letterSpacing: n ? `${n}px` : undefined });
}
function setMeasureCh(n: number | undefined) {
onPatch({ maxWidth: n ? `${n}ch` : undefined });
}
/** Visible trigger text for a Select — the matched option's LABEL. (Select.Value
* shows the raw VALUE while its menu/labels are unmounted, i.e. when the value is
* set programmatically rather than picked, so we render the label ourselves.) */
function labelOf(
opts: readonly { value: string | number | boolean; label: string }[],
sel: string[]
): string | undefined {
return opts.find((o) => String(o.value) === sel[0])?.label;
}
</script>
<!-- Preset dropdown (canonical theme steps). Empty selection → placeholder, shown
while a MANUAL value owns the prop. -->
{#snippet presetSelect(
sel: string[],
opts: readonly PalabrasFieldOption[],
onPick: (v: string | undefined) => void,
placeholder: string
)}
<Select
type="single"
size="xs"
variant="surface"
value={sel}
{disabled}
onValueChange={(v) => onPick(v[0])}
>
<Select.Trigger>
<span data-palabras-select-text data-empty={sel.length ? undefined : ''}
>{labelOf(opts, sel) ?? placeholder}</span
>
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content data-palabras-type-select data-words-external-tool>
<Select.Viewport>
{#each opts as o (o.value)}
<Select.Item value={String(o.value)} label={o.label}>
<span>{o.label}</span>
<Select.ItemIndicator />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
{/snippet}
<!-- Manual slider (half of a slider | input pair). -->
{#snippet sliderCtl(
val: number,
min: number,
max: number,
step: number,
onSlide: (n: number) => void,
label: string
)}
<Slider
size="xs"
value={[val]}
{min}
{max}
{step}
{disabled}
onValueChange={(v) => onSlide(v[0])}
aria-label={label}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
{/snippet}
<div data-palabras-typography data-disabled={disabled ? '' : undefined}>
<!-- Familia — grouped dropdown (theme fonts + schema fonts); each option previews
its own typeface. -->
@ -172,7 +268,9 @@
onValueChange={(v) => pickFamily(v[0])}
>
<Select.Trigger>
<Select.Value placeholder="Por defecto" />
<span data-palabras-select-text data-empty={fontValue.length ? undefined : ''}
>{labelOf(allFonts, fontValue) ?? 'Por defecto'}</span
>
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
@ -269,7 +367,9 @@
onValueChange={(v) => pickWeight(v[0])}
>
<Select.Trigger>
<Select.Value placeholder="Por defecto" />
<span data-palabras-select-text data-empty={weightValue.length ? undefined : ''}
>{labelOf(WEIGHTS, weightValue) ?? 'Por defecto'}</span
>
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
@ -291,53 +391,47 @@
</Select>
</div>
<!-- Interlineado (Leading) — scale chips + manual slider (1–2). Same prop. -->
<!-- Interlineado (Leading) — preset dropdown + a manual slider | number pair. -->
<div data-palabras-field data-palabras-type-scale>
<span data-palabras-field-label>Interlineado</span>
<PalabrasSegmented
value={leadingActive}
options={LEADING}
{disabled}
ariaLabel="Interlineado"
onValueChange={pickLeading}
/>
<Slider
size="xs"
value={[leadingSlider]}
min={1}
max={2}
step={0.05}
{disabled}
onValueChange={(v) => setLeading(v[0])}
aria-label="Interlineado manual"
>
<Slider.Range />
<Slider.Thumb />
</Slider>
{@render presetSelect(leadingSel, LEADING, pickLeading, 'Personalizado')}
<div data-palabras-type-pair-row>
{@render sliderCtl(leadingSlider, 1, 2, 0.05, setLeading, 'Interlineado')}
<NumberField
size="xs"
value={leadingSlider}
min={0.5}
max={3}
step={0.05}
{disabled}
onValueChange={(n) => setLeading(n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input placeholder="auto" />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
</div>
<!-- Tracking — scale chips + manual px/em field. Same prop (letter-spacing). -->
<!-- Tracking — preset dropdown + a manual slider | px·em field pair. -->
<div data-palabras-field data-palabras-type-scale>
<span data-palabras-field-label>Tracking</span>
<PalabrasSegmented
value={trackingActive}
options={TRACKING}
{disabled}
ariaLabel="Tracking"
onValueChange={(v) => setStr('letterSpacing', v)}
/>
<CssField
size="xs"
steppers="stacked"
value={value.letterSpacing}
allowedUnits={['px', 'em']}
{disabled}
onValueChange={(v) => setStr('letterSpacing', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="0" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
{@render presetSelect(trackingSel, TRACKING, (v) => setStr('letterSpacing', v), 'Personalizado')}
<div data-palabras-type-pair-row>
{@render sliderCtl(trackingSlider, -10, 10, 0.5, setLetterPx, 'Tracking')}
<CssField
size="xs"
steppers="stacked"
value={value.letterSpacing}
allowedUnits={['px', 'em']}
{disabled}
onValueChange={(v) => setStr('letterSpacing', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="0" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
</div>
<!-- Espaciado — word-spacing (no theme preset; manual only). -->
@ -357,7 +451,7 @@
</CssField>
</div>
<!-- Medida (measure) — scale chips + manual ch/rem field. Same prop (max-width). -->
<!-- Medida (measure) — preset chips + a manual slider | ch field pair. -->
<div data-palabras-field data-palabras-type-scale>
<span data-palabras-field-label>Medida</span>
<PalabrasSegmented
@ -367,62 +461,40 @@
ariaLabel="Ancho de línea"
onValueChange={(v) => setStr('maxWidth', v)}
/>
<CssField
size="xs"
steppers="stacked"
value={value.maxWidth}
allowedUnits={['ch', 'rem', 'px']}
allowNegative={false}
{disabled}
onValueChange={(v) => setStr('maxWidth', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="auto" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
<!-- Ajuste (Wrap) — text-wrap mode. A dropdown: the mode labels are too long for
an attached pill row. -->
<div data-palabras-field>
<span data-palabras-field-label>Ajuste</span>
<Select
type="single"
size="xs"
variant="surface"
value={wrapValue}
{disabled}
onValueChange={(v) => pickWrap(v[0])}
>
<Select.Trigger>
<Select.Value placeholder="Normal" />
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content data-palabras-type-select data-words-external-tool>
<Select.Viewport>
{#each WRAP_OPTS as o (o.value)}
<Select.Item value={String(o.value)} label={o.label}>
<span>{o.label}</span>
<Select.ItemIndicator />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
<div data-palabras-type-pair-row>
{@render sliderCtl(measureSlider, 20, 120, 1, setMeasureCh, 'Medida')}
<CssField
size="xs"
steppers="stacked"
value={value.maxWidth}
allowedUnits={['ch', 'rem', 'px']}
allowNegative={false}
{disabled}
onValueChange={(v) => setStr('maxWidth', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="auto" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
</div>
<!-- Cifras tabulares — fixed-width figures. -->
<div data-palabras-field data-inline>
<span data-palabras-field-label>Cifras tabulares</span>
<Checkbox
size="xs"
checked={!!value.tabularNumbers}
{disabled}
onCheckedChange={(c) => onPatch({ tabularNumbers: c || undefined })}
>
<Checkbox.Indicator />
</Checkbox>
<!-- Ajuste (Wrap) + Cifras tabulares share one row (same height). -->
<div data-palabras-type-inline-pair>
<div data-palabras-field>
<span data-palabras-field-label>Ajuste</span>
{@render presetSelect(wrapValue, WRAP_OPTS, pickWrap, 'Normal')}
</div>
<div data-palabras-field data-inline>
<span data-palabras-field-label>Cifras tabulares</span>
<Checkbox
size="xs"
checked={!!value.tabularNumbers}
{disabled}
onCheckedChange={(c) => onPatch({ tabularNumbers: c || undefined })}
>
<Checkbox.Indicator />
</Checkbox>
</div>
</div>
</div>

@ -550,37 +550,56 @@
align-items: center;
gap: var(--space-2);
}
/* Tamaño — the slider grows; the px field is a fixed compact box beside it. */
/* Slider | input pair — 50 / 50 (Tamaño, and the manual half of each scale). */
[data-palabras-type-pair-row] {
display: flex;
align-items: center;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
}
[data-palabras-type-pair-row] > :first-child {
flex: 1 1 auto;
align-items: center;
/* The slider's own `min-inline-size: var(--slider-min-inline-size)` (12rem) would
keep its 1fr column from shrinking; zero the token (inherited by the slider) so
both cells reach a true 50 / 50. */
--slider-min-inline-size: 0;
}
/* Grid items default to `min-width: auto` (their content min-size). The slider's
intrinsic min-width would otherwise eat the row and starve the input to a few px,
so let both cells shrink to a true 1fr (50 / 50). */
[data-palabras-type-pair-row] > * {
min-inline-size: 0;
}
[data-palabras-type-pair-row] > :last-child {
flex: 0 0 4.5rem;
}
/* Scale controls (Interlineado / Tracking / Measure) — preset chips ABOVE the
manual input (same prop). The `attached` chip row can exceed the panel width;
let it scroll rather than break the layout. */
/* Scale controls keep their preset row ABOVE the slider|input pair. Only Medida
still uses chips (Interlineado / Tracking are dropdowns now); its three steps fit
at the panel's default chip size, so no shrinking is needed. */
[data-palabras-type-scale] {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
}
[data-palabras-type-scale] [data-toggle-group] {
max-inline-size: 100%;
overflow-x: auto;
/* Ajuste (select) + Cifras tabulares (checkbox) on one row, same height. */
[data-palabras-type-inline-pair] {
display: flex;
align-items: center;
gap: var(--space-3);
}
/* Canonical step NAMES are wider than numbers — pack the scale chips (xxs font +
slim padding) so all six leading steps fit without truncation. Beats the
panel-wide `[data-toggle-group-item]` 12px rule (0,4,0 > 0,3,0). */
[data-palabras-panel][data-palabras-panel] [data-palabras-type-scale] [data-toggle-group-item] {
font-size: var(--font-size-xxs);
padding-inline: 2px;
[data-palabras-type-inline-pair] > [data-palabras-field]:first-child {
flex: 1 1 auto;
min-inline-size: 0;
}
[data-palabras-type-inline-pair] > [data-palabras-field]:last-child {
flex: 0 0 auto;
}
/* Trigger text we render ourselves (Select.Value shows the raw value while its menu
is unmounted). Fill the trigger, truncate, mute the placeholder. */
[data-palabras-select-text] {
flex: 1 1 auto;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start;
}
[data-palabras-select-text][data-empty] {
color: var(--color-content-muted);
}
/* Toggle pills (Nivel): the ToggleGroup recipe renders `xs` at 10px, but the

@ -0,0 +1,301 @@
/* ── spin-field — shared stepper-field visual ─────────────────────────────────
The single source of the "field with increment/decrement spin controls"
visual. NumberField and CssField are the same component visually (a bordered
field + input + increment/decrement triggers + scrubber, split/stacked layouts,
sizes/variants/colors, themeable glyphs); only their value model differs (soma:
number vs CSS value). Rather than clone this CSS per component — which drifted —
both declare structural identity in their morfo (`data-spin-field*`, like the
toggle-group → toggle pattern) and reuse these rules + the `spin-field` recipe
tokens. A theme tints per component by scoping `[data-number-field] { --spin-
field-… }` (inherited by the steppers); the default is shared.
Loaded via the foundation `@import` in `index.css` (no `spin-field.svelte` to
self-import it). `data-size` / `data-variant` / `data-color` / `data-steppers`
are set by each eidos wrapper on the same root element that carries
`data-spin-field`. */
[data-spin-field] {
--_spin-field-height: var(--spin-field-height-md);
--_spin-field-px: var(--spin-field-px-md);
--_spin-field-gap: var(--spin-field-gap-md);
--_spin-field-font-size: var(--spin-field-font-size-md);
display: inline-flex;
align-items: center;
inline-size: 100%;
min-inline-size: 0;
block-size: var(--_spin-field-height);
padding-inline: var(--_spin-field-px);
border: var(--spin-field-border-width) solid var(--spin-field-border);
border-radius: var(--spin-field-radius);
background: var(--spin-field-bg);
/* The edge steppers break OUT of this inline padding (negative margin) so they
sit FLUSH against the field border; `overflow` then clips them to the
rounded corners. The padding still spaces the input from the field ends. */
overflow: hidden;
color: var(--spin-field-color);
font-family: var(--spin-field-font-family);
font-size: var(--_spin-field-font-size);
line-height: var(--spin-field-line-height);
transition:
background var(--spin-field-transition-duration) var(--spin-field-transition-ease),
border-color var(--spin-field-transition-duration) var(--spin-field-transition-ease),
box-shadow var(--spin-field-transition-duration) var(--spin-field-transition-ease);
}
[data-spin-field][data-size='xs'] {
--_spin-field-height: var(--spin-field-height-xs);
--_spin-field-px: var(--spin-field-px-xs);
--_spin-field-gap: var(--spin-field-gap-xs);
--_spin-field-font-size: var(--spin-field-font-size-xs);
}
[data-spin-field][data-size='sm'] {
--_spin-field-height: var(--spin-field-height-sm);
--_spin-field-px: var(--spin-field-px-sm);
--_spin-field-gap: var(--spin-field-gap-sm);
--_spin-field-font-size: var(--spin-field-font-size-sm);
}
[data-spin-field][data-size='lg'] {
--_spin-field-height: var(--spin-field-height-lg);
--_spin-field-px: var(--spin-field-px-lg);
--_spin-field-gap: var(--spin-field-gap-lg);
--_spin-field-font-size: var(--spin-field-font-size-lg);
}
[data-spin-field][data-size='xl'] {
--_spin-field-height: var(--spin-field-height-xl);
--_spin-field-px: var(--spin-field-px-xl);
--_spin-field-gap: var(--spin-field-gap-xl);
--_spin-field-font-size: var(--spin-field-font-size-xl);
}
[data-spin-field][data-variant='outline'] {
background: transparent;
}
[data-spin-field][data-variant='ghost'] {
border-color: transparent;
background: transparent;
padding-inline: 0;
}
[data-spin-field][data-focused],
[data-spin-field][data-scrubbing] {
border-color: var(--spin-field-border-focus);
box-shadow: var(--spin-field-focus-shadow);
}
[data-spin-field][data-invalid] {
border-color: var(--spin-field-border-invalid);
}
[data-spin-field][data-disabled] {
opacity: var(--spin-field-disabled-opacity);
}
[data-spin-field-input] {
flex: 1 1 auto;
order: 1;
min-inline-size: 0;
/* No gap on the field — the steppers butt right up against the input via their
divider. This padding (the former gap value) keeps the text off the divider. */
padding-inline: var(--_spin-field-gap);
border: 0;
background: transparent;
color: inherit;
font: inherit;
outline: none;
transition: box-shadow var(--spin-field-transition-duration)
var(--spin-field-transition-ease);
}
/* Split layout (default) — decrement flush at the inline-start edge, increment
flush at the inline-end edge, scrubber tucked just inside the increment.
`order` makes the arrangement independent of composition order and flips under
RTL; the negative margin on the two end triggers cancels the field's inline
padding so they reach the border; the divider sits on each trigger's INNER
side (decrement → end, increment → start), flipping under RTL. */
[data-spin-field]:not([data-steppers='stacked']) [data-spin-field-decrement-trigger] {
order: 0;
margin-inline-start: calc(-1 * var(--_spin-field-px));
border-inline-end: var(--spin-field-control-border-width) solid
var(--spin-field-control-divider);
}
[data-spin-field]:not([data-steppers='stacked']) [data-spin-field-scrubber] {
order: 2;
}
[data-spin-field]:not([data-steppers='stacked']) [data-spin-field-increment-trigger] {
order: 3;
margin-inline-end: calc(-1 * var(--_spin-field-px));
border-inline-start: var(--spin-field-control-border-width) solid
var(--spin-field-control-divider);
}
[data-spin-field-input][data-disabled] {
cursor: not-allowed;
}
/* Focus is shown once, by the root's canonical two-ring focus-shadow
(`[data-spin-field][data-focused]`). No separate ring on the input — that
stacked a second (inner) frame inside the root ring. */
[data-spin-field-increment-trigger],
[data-spin-field-decrement-trigger],
[data-spin-field-scrubber] {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
/* Stepper buttons and scrubber inherit the Provider's resolved height so
they always match the input row, regardless of size token. */
inline-size: var(--_spin-field-height);
block-size: var(--_spin-field-height);
min-inline-size: var(--_spin-field-height);
border: var(--spin-field-control-border-width) solid var(--spin-field-control-border);
border-radius: var(--spin-field-control-radius);
background: var(--spin-field-control-bg);
color: var(--spin-field-control-color);
font: inherit;
outline: none;
transition:
background var(--spin-field-transition-duration) var(--spin-field-transition-ease),
border-color var(--spin-field-transition-duration) var(--spin-field-transition-ease),
color var(--spin-field-transition-duration) var(--spin-field-transition-ease),
box-shadow var(--spin-field-transition-duration) var(--spin-field-transition-ease);
}
[data-spin-field-increment-trigger],
[data-spin-field-decrement-trigger] {
cursor: pointer;
}
[data-spin-field-scrubber] {
cursor: ew-resize;
}
[data-spin-field-increment-trigger]:hover:not([data-disabled]),
[data-spin-field-decrement-trigger]:hover:not([data-disabled]),
[data-spin-field-scrubber]:hover:not([data-disabled]) {
border-color: var(--spin-field-control-border-hover);
background: var(--spin-field-control-bg-hover);
color: var(--spin-field-control-color-hover);
}
[data-spin-field-increment-trigger]:focus-visible,
[data-spin-field-decrement-trigger]:focus-visible,
[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);
}
[data-spin-field-increment-trigger][data-disabled],
[data-spin-field-decrement-trigger][data-disabled],
[data-spin-field-scrubber][data-disabled] {
cursor: default;
opacity: var(--spin-field-control-disabled-opacity);
}
[data-spin-field-scrubber][data-scrubbing] {
border-color: var(--spin-field-control-border-active);
background: var(--spin-field-control-bg-active);
color: var(--spin-field-control-color-active);
}
/* ── Default stepper glyphs (themeable) ───────────────────────────────────────
A trigger with NO children renders the glyph from a token via `:empty::before`;
passing children overrides it. The glyph is decorative — the button is labeled
by its aria-label. The internal var picks the split (+/−) or stacked (▲/▼) token
so a single `content` rule serves both layouts; a theme overrides any token. */
[data-spin-field-increment-trigger] {
--_spin-field-increment-glyph: var(--spin-field-control-increment-glyph);
}
[data-spin-field-decrement-trigger] {
--_spin-field-decrement-glyph: var(--spin-field-control-decrement-glyph);
}
[data-spin-field][data-steppers='stacked'] [data-spin-field-increment-trigger] {
--_spin-field-increment-glyph: var(--spin-field-control-increment-glyph-stacked);
}
[data-spin-field][data-steppers='stacked'] [data-spin-field-decrement-trigger] {
--_spin-field-decrement-glyph: var(--spin-field-control-decrement-glyph-stacked);
}
[data-spin-field-increment-trigger]:empty::before {
content: var(--_spin-field-increment-glyph);
}
[data-spin-field-decrement-trigger]:empty::before {
content: var(--_spin-field-decrement-glyph);
}
/* ── Stacked steppers — compact column on the inline-end side ─────────────────
A 2×2 grid: the input fills the inline-start, the increment (top) and
decrement (bottom) share one narrow column on the inline-end. The grid mirrors
under RTL, so the column moves to the inline-start (left) automatically. */
[data-spin-field][data-steppers='stacked'] {
display: grid;
grid-template-columns: 1fr auto;
/* `minmax(0, …)` lets each row be EXACTLY half the field height. Plain `1fr`
has an implicit `min-content` floor, so a glyph taller than half the field
(small sizes) would grow its row and push the other past the clipped edge —
the bug where decrement looked cut off and the two arrows were unequal. */
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'input increment'
'input decrement';
/* The base field rule sets `align-items: center` for its inline-flex layout;
once we switch to grid that would center each arrow at its content height
instead of filling its row. Restore the grid default so each arrow stretches
to half the field height — making the stepper height a function of `size`. */
align-items: stretch;
/* The stepper column reaches the edge itself, so the field drops its inline
padding; the input carries it instead. */
padding-inline: 0;
}
[data-spin-field][data-steppers='stacked'] [data-spin-field-input] {
grid-area: input;
padding-inline: var(--_spin-field-px);
}
[data-spin-field][data-steppers='stacked'] [data-spin-field-increment-trigger],
[data-spin-field][data-steppers='stacked'] [data-spin-field-decrement-trigger] {
inline-size: calc(var(--_spin-field-height) * 0.62);
min-inline-size: calc(var(--_spin-field-height) * 0.62);
/* each arrow takes half the field height (the grid row), not the full height */
block-size: auto;
/* the split layout's negative edge margins don't apply here */
margin: 0;
/* vertical divider toward the input */
border-inline-start: var(--spin-field-control-border-width) solid
var(--spin-field-border);
/* Glyph scales with the field height (≈ a third of it), so it stays inside the
half-height cell at every size — including `xs`, where a fixed `0.9em` was too
tall and overflowed. Scales up cleanly through `xl`. */
font-size: calc(var(--_spin-field-height) * 0.34);
line-height: 1;
}
[data-spin-field][data-steppers='stacked'] [data-spin-field-increment-trigger] {
grid-area: increment;
}
[data-spin-field][data-steppers='stacked'] [data-spin-field-decrement-trigger] {
grid-area: decrement;
/* horizontal divider between the two arrows */
border-block-start: var(--spin-field-control-border-width) solid
var(--spin-field-border);
}
/* No room for the pointer scrubber in the stacked layout. */
[data-spin-field][data-steppers='stacked'] [data-spin-field-scrubber] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
[data-spin-field],
[data-spin-field-increment-trigger],
[data-spin-field-decrement-trigger],
[data-spin-field-scrubber] {
transition: none;
}
}

@ -1218,108 +1218,58 @@
--meter-indicator-bg-above: var(--color-threat-solid);
--meter-transition-duration: var(--duration-normal);
--meter-transition-ease: var(--ease-default);
--number-field-height-xs: var(--control-height-xs);
--number-field-height-sm: var(--control-height-sm);
--number-field-height-md: var(--control-height-md);
--number-field-height-lg: var(--control-height-lg);
--number-field-height-xl: var(--control-height-xl);
--number-field-px-xs: var(--space-2);
--number-field-px-sm: var(--space-2-5);
--number-field-px-md: var(--space-3);
--number-field-px-lg: var(--space-3-5);
--number-field-px-xl: var(--space-4);
--number-field-gap-xs: var(--space-0-5);
--number-field-gap-sm: var(--space-1);
--number-field-gap-md: var(--space-1-5);
--number-field-gap-lg: var(--space-2);
--number-field-gap-xl: var(--space-2-5);
--number-field-font-family: var(--font-ui);
--number-field-font-size-xs: var(--font-size-xs);
--number-field-font-size-sm: var(--font-size-xs);
--number-field-font-size-md: var(--font-size-sm);
--number-field-font-size-lg: var(--font-size-sm);
--number-field-font-size-xl: var(--font-size-md);
--number-field-line-height: var(--leading-ui);
--number-field-radius: var(--radius-md);
--number-field-border-width: var(--border-width);
--number-field-border: var(--color-border-default);
--number-field-border-focus: var(--color-focus-ring);
--number-field-border-invalid: var(--color-risk-border);
--number-field-bg: var(--color-surface-default);
--number-field-color: var(--color-content-primary);
--number-field-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);
--number-field-control-radius: 0;
--number-field-control-border-width: var(--border-width);
--number-field-control-border: transparent;
--number-field-control-divider: var(--color-border-default);
--number-field-control-border-hover: var(--color-border-strong);
--number-field-control-border-active: var(--color-primary-border);
--number-field-control-bg: transparent;
--number-field-control-bg-hover: var(--color-neutral-hover);
--number-field-control-bg-focus: var(--color-neutral-hover);
--number-field-control-bg-active: var(--color-primary-track);
--number-field-control-color: var(--color-content-secondary);
--number-field-control-color-hover: var(--color-content-primary);
--number-field-control-color-active: var(--color-primary-text);
--number-field-control-increment-glyph: '+';
--number-field-control-decrement-glyph: '\2212';
--number-field-control-increment-glyph-stacked: '\25B2';
--number-field-control-decrement-glyph-stacked: '\25BC';
--number-field-control-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);
--number-field-control-disabled-opacity: 0.48;
--number-field-transition-duration: var(--duration-fast);
--number-field-transition-ease: var(--ease-default);
--number-field-disabled-opacity: 0.56;
--css-field-height-xs: var(--control-height-xs);
--css-field-height-sm: var(--control-height-sm);
--css-field-height-md: var(--control-height-md);
--css-field-height-lg: var(--control-height-lg);
--css-field-height-xl: var(--control-height-xl);
--css-field-px-xs: var(--space-2);
--css-field-px-sm: var(--space-2-5);
--css-field-px-md: var(--space-3);
--css-field-px-lg: var(--space-3-5);
--css-field-px-xl: var(--space-4);
--css-field-gap-xs: var(--space-0-5);
--css-field-gap-sm: var(--space-1);
--css-field-gap-md: var(--space-1-5);
--css-field-gap-lg: var(--space-2);
--css-field-gap-xl: var(--space-2-5);
--css-field-font-family: var(--font-ui);
--css-field-font-size-xs: var(--font-size-xs);
--css-field-font-size-sm: var(--font-size-xs);
--css-field-font-size-md: var(--font-size-sm);
--css-field-font-size-lg: var(--font-size-sm);
--css-field-font-size-xl: var(--font-size-md);
--css-field-line-height: var(--leading-ui);
--css-field-radius: var(--radius-md);
--css-field-border-width: var(--border-width);
--css-field-border: var(--color-border-default);
--css-field-border-focus: var(--color-focus-ring);
--css-field-border-invalid: var(--color-risk-border);
--css-field-bg: var(--color-surface-default);
--css-field-color: var(--color-content-primary);
--css-field-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);
--css-field-control-radius: var(--radius-md);
--css-field-control-border-width: var(--border-width);
--css-field-control-border: transparent;
--css-field-control-border-hover: var(--color-border-subtle);
--css-field-control-border-active: var(--color-primary-border);
--css-field-control-bg: transparent;
--css-field-control-bg-hover: var(--color-surface-raised);
--css-field-control-bg-active: var(--color-primary-track);
--css-field-control-color: var(--color-content-secondary);
--css-field-control-color-hover: var(--color-content-primary);
--css-field-control-color-active: var(--color-primary-text);
--css-field-control-increment-glyph: '+';
--css-field-control-decrement-glyph: '\2212';
--css-field-control-increment-glyph-stacked: '\25B2';
--css-field-control-decrement-glyph-stacked: '\25BC';
--css-field-control-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);
--css-field-control-disabled-opacity: 0.48;
--css-field-transition-duration: var(--duration-fast);
--css-field-transition-ease: var(--ease-default);
--css-field-disabled-opacity: 0.56;
--spin-field-height-xs: var(--control-height-xs);
--spin-field-height-sm: var(--control-height-sm);
--spin-field-height-md: var(--control-height-md);
--spin-field-height-lg: var(--control-height-lg);
--spin-field-height-xl: var(--control-height-xl);
--spin-field-px-xs: var(--space-2);
--spin-field-px-sm: var(--space-2-5);
--spin-field-px-md: var(--space-3);
--spin-field-px-lg: var(--space-3-5);
--spin-field-px-xl: var(--space-4);
--spin-field-gap-xs: var(--space-0-5);
--spin-field-gap-sm: var(--space-1);
--spin-field-gap-md: var(--space-1-5);
--spin-field-gap-lg: var(--space-2);
--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-line-height: var(--leading-ui);
--spin-field-radius: var(--radius-md);
--spin-field-border-width: var(--border-width);
--spin-field-border: var(--color-border-default);
--spin-field-border-focus: var(--color-focus-ring);
--spin-field-border-invalid: var(--color-risk-border);
--spin-field-bg: var(--color-surface-default);
--spin-field-color: var(--color-content-primary);
--spin-field-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);
--spin-field-control-radius: 0;
--spin-field-control-border-width: var(--border-width);
--spin-field-control-border: transparent;
--spin-field-control-divider: var(--color-border-default);
--spin-field-control-border-hover: var(--color-border-strong);
--spin-field-control-border-active: var(--color-primary-border);
--spin-field-control-bg: transparent;
--spin-field-control-bg-hover: var(--color-neutral-hover);
--spin-field-control-bg-focus: var(--color-neutral-hover);
--spin-field-control-bg-active: var(--color-primary-track);
--spin-field-control-color: var(--color-content-secondary);
--spin-field-control-color-hover: var(--color-content-primary);
--spin-field-control-color-active: var(--color-primary-text);
--spin-field-control-increment-glyph: '+';
--spin-field-control-decrement-glyph: '\2212';
--spin-field-control-increment-glyph-stacked: '\25B2';
--spin-field-control-decrement-glyph-stacked: '\25BC';
--spin-field-control-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);
--spin-field-control-disabled-opacity: 0.48;
--spin-field-transition-duration: var(--duration-fast);
--spin-field-transition-ease: var(--ease-default);
--spin-field-disabled-opacity: 0.56;
--float-panel-bg: var(--color-surface-overlay);
--float-panel-border: var(--color-border-default);
--float-panel-border-width: var(--border-width);

@ -78,3 +78,11 @@
@import './components/section/section.css';
@import './components/aspect-ratio/aspect-ratio.css';
@import './components/auto-grid/auto-grid.css';
/*
* spin-field — shared stepper-field visual reused by number-field + css-field
* via structural identity (`data-spin-field*`). It has no `.svelte` of its own
* to self-import its CSS, so (like the layout primitives) it loads here. The
* trade-off is that it ships even on pages with no spin-field; in exchange there
* is ONE source of the stepper-field visual instead of two clones that drift.
*/
@import './components/spin-field/spin-field.css';

@ -1042,7 +1042,11 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-duration': 'var(--duration-normal)',
'transition-ease': 'var(--ease-default)'
},
'number-field': {
// spin-field — shared visual for the stepper-field family (number-field +
// css-field). Both reuse these tokens via structural identity (`data-spin-
// field*`) and the shared `spin-field.css`. A theme tints per component by
// scoping `[data-number-field] { --spin-field-… }`. See THEMING §34.
'spin-field': {
'height-xs': 'var(--control-height-xs)',
'height-sm': 'var(--control-height-sm)',
'height-md': 'var(--control-height-md)',
@ -1106,64 +1110,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.56'
},
'css-field': {
'height-xs': 'var(--control-height-xs)',
'height-sm': 'var(--control-height-sm)',
'height-md': 'var(--control-height-md)',
'height-lg': 'var(--control-height-lg)',
'height-xl': 'var(--control-height-xl)',
'px-xs': 'var(--space-2)',
'px-sm': 'var(--space-2-5)',
'px-md': 'var(--space-3)',
'px-lg': 'var(--space-3-5)',
'px-xl': 'var(--space-4)',
'gap-xs': 'var(--space-0-5)',
'gap-sm': 'var(--space-1)',
'gap-md': 'var(--space-1-5)',
'gap-lg': 'var(--space-2)',
'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)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
border: 'var(--color-border-default)',
'border-focus': 'var(--color-focus-ring)',
'border-invalid': 'var(--color-risk-border)',
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'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)',
'control-radius': 'var(--radius-md)',
'control-border-width': 'var(--border-width)',
'control-border': 'transparent',
'control-border-hover': 'var(--color-border-subtle)',
'control-border-active': 'var(--color-primary-border)',
'control-bg': 'transparent',
'control-bg-hover': 'var(--color-surface-raised)',
'control-bg-active': 'var(--color-primary-track)',
'control-color': 'var(--color-content-secondary)',
'control-color-hover': 'var(--color-content-primary)',
'control-color-active': 'var(--color-primary-text)',
// Default stepper glyphs — themeable, decorative (the buttons carry an
// aria-label). Rendered via `:empty::before`, so passing children to a
// trigger overrides them. Split uses +/−; the stacked column uses up/down
// triangles. A theme retints/reshapes by overriding any of the four.
'control-increment-glyph': "'+'",
'control-decrement-glyph': "'\\2212'",
'control-increment-glyph-stacked': "'\\25B2'",
'control-decrement-glyph-stacked': "'\\25BC'",
'control-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)',
'control-disabled-opacity': '0.48',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.56'
},
'float-panel': {
bg: 'var(--color-surface-overlay)',
border: 'var(--color-border-default)',

@ -70,6 +70,10 @@ export const cssFieldMorfo = {
defaultElement: 'div',
optional: false,
data: [
// Structural identity: CssField IS a spin-field. These presence attrs
// let the shared spin-field eidos visual style it without cloning CSS
// per component (see eidos/components/spin-field).
{ attr: 'data-spin-field', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
@ -88,6 +92,7 @@ export const cssFieldMorfo = {
role: 'spinbutton',
optional: false,
data: [
{ attr: 'data-spin-field-input', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' }
@ -120,7 +125,10 @@ export const cssFieldMorfo = {
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
data: [
{ attr: 'data-spin-field-increment-trigger', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{
@ -144,7 +152,10 @@ export const cssFieldMorfo = {
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
data: [
{ attr: 'data-spin-field-decrement-trigger', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{
@ -169,6 +180,7 @@ export const cssFieldMorfo = {
role: 'presentation',
optional: true,
data: [
{ attr: 'data-spin-field-scrubber', value: v.literal(''), severity: 'required' },
{ attr: 'data-scrubbing', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
],

@ -50,6 +50,10 @@ export const numberFieldMorfo = {
defaultElement: 'div',
optional: false,
data: [
// Structural identity: NumberField IS a spin-field. These presence
// attrs let the shared spin-field eidos visual style it without
// cloning CSS per component (see eidos/components/spin-field).
{ attr: 'data-spin-field', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
@ -68,6 +72,7 @@ export const numberFieldMorfo = {
role: 'spinbutton',
optional: false,
data: [
{ attr: 'data-spin-field-input', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' }
@ -112,7 +117,10 @@ export const numberFieldMorfo = {
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
data: [
{ attr: 'data-spin-field-increment-trigger', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{
@ -136,7 +144,10 @@ export const numberFieldMorfo = {
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
data: [
{ attr: 'data-spin-field-decrement-trigger', value: v.literal(''), severity: 'required' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{
@ -161,6 +172,7 @@ export const numberFieldMorfo = {
role: 'presentation',
optional: true,
data: [
{ attr: 'data-spin-field-scrubber', value: v.literal(''), severity: 'required' },
{ attr: 'data-scrubbing', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
],

@ -216,8 +216,7 @@ export class CssFieldProvider {
() => this.opts.required.current || (this.field?.isRequired ?? false)
);
readonly isInvalid = $derived.by(
() =>
this.opts.invalid.current || (this.field?.isInvalid ?? false) || this.internalInvalid
() => this.opts.invalid.current || (this.field?.isInvalid ?? false) || this.internalInvalid
);
readonly dir = $derived.by(() => this.resolvedDir.current);
@ -533,6 +532,7 @@ export class CssFieldInputProvider {
...this.runtimePart.props,
role: 'spinbutton' as const,
inputmode: 'text' as const,
'data-spin-field-input': '',
autocomplete: 'off' as const,
autocorrect: 'off' as const,
spellcheck: false,
@ -617,6 +617,7 @@ export class CssFieldIncrementTriggerProvider {
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label': this.provider.soma.langs.ts(CSS_FIELD_LANGS.INCREMENT),
'data-spin-field-increment-trigger': '',
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.handleClick,
@ -683,6 +684,7 @@ export class CssFieldDecrementTriggerProvider {
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label': this.provider.soma.langs.ts(CSS_FIELD_LANGS.DECREMENT),
'data-spin-field-decrement-trigger': '',
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.handleClick,
@ -814,6 +816,7 @@ export class CssFieldScrubberProvider {
...this.runtimePart.props,
role: 'presentation' as const,
'aria-hidden': true as const,
'data-spin-field-scrubber': '',
'data-scrubbing': boolToEmptyStrOrUndef(this.provider.scrubbing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
style: {

@ -594,6 +594,7 @@ export class NumberFieldInputProvider {
...this.runtimePart.props,
role: 'spinbutton' as const,
inputmode: 'decimal' as const,
'data-spin-field-input': '',
autocomplete: 'off' as const,
autocorrect: 'off' as const,
spellcheck: false,
@ -681,6 +682,7 @@ export class NumberFieldIncrementTriggerProvider {
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label': this.provider.soma.langs.ts(NUMBER_FIELD_LANGS.INCREMENT),
'data-spin-field-increment-trigger': '',
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.handleClick,
@ -747,6 +749,7 @@ export class NumberFieldDecrementTriggerProvider {
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label': this.provider.soma.langs.ts(NUMBER_FIELD_LANGS.DECREMENT),
'data-spin-field-decrement-trigger': '',
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.handleClick,
@ -883,6 +886,7 @@ export class NumberFieldScrubberProvider {
...this.runtimePart.props,
role: 'presentation' as const,
'aria-hidden': true as const,
'data-spin-field-scrubber': '',
'data-scrubbing': boolToEmptyStrOrUndef(this.provider.scrubbing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
style: {

@ -467,9 +467,10 @@
</div>
<p data-uix-section-desc>
Triggers render a default glyph from a token, so a theme can restyle every stepper at
once — override <code>--number-field-control-increment-glyph</code> /
<code>-decrement-glyph</code> (and their <code>-stacked</code> siblings). Passing children
to a trigger overrides the token for that instance.
once — override <code>--spin-field-control-increment-glyph</code> /
<code>-decrement-glyph</code> (and their <code>-stacked</code> siblings). The tokens are
shared by NumberField and CssField (both are spin-fields); passing children to a trigger
overrides the token for that instance.
</p>
<div
style="display:flex; flex-wrap:wrap; gap:var(--space-6); align-items:flex-start; margin-block-start:var(--space-2)"
@ -481,7 +482,7 @@
min={0}
max={100}
aria-label="Token override"
style="--number-field-control-increment-glyph: '⊕'; --number-field-control-decrement-glyph: '⊖'"
style="--spin-field-control-increment-glyph: '⊕'; --spin-field-control-decrement-glyph: '⊖'"
>
<NumberField.DecrementTrigger />
<NumberField.Input aria-label="Token override stepper" />

Loading…
Cancel
Save

Powered by TurnKey Linux.