diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md
index eb55447ca..230b4ce76 100644
--- a/src/uix/eidos/THEMING.md
+++ b/src/uix/eidos/THEMING.md
@@ -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
diff --git a/src/uix/eidos/components/css-field/README.md b/src/uix/eidos/components/css-field/README.md
index 80fc1ea6e..daccd5e2f 100644
--- a/src/uix/eidos/components/css-field/README.md
+++ b/src/uix/eidos/components/css-field/README.md
@@ -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
diff --git a/src/uix/eidos/components/css-field/css-field.css b/src/uix/eidos/components/css-field/css-field.css
index e057959e1..695c226b4 100644
--- a/src/uix/eidos/components/css-field/css-field.css
+++ b/src/uix/eidos/components/css-field/css-field.css
@@ -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-… }`. */
diff --git a/src/uix/eidos/components/number-field/README.md b/src/uix/eidos/components/number-field/README.md
index 71590cdf6..0857608c6 100644
--- a/src/uix/eidos/components/number-field/README.md
+++ b/src/uix/eidos/components/number-field/README.md
@@ -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 |
diff --git a/src/uix/eidos/components/number-field/number-field.css b/src/uix/eidos/components/number-field/number-field.css
index 6fb582167..c54ba136c 100644
--- a/src/uix/eidos/components/number-field/number-field.css
+++ b/src/uix/eidos/components/number-field/number-field.css
@@ -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-… }`. */
diff --git a/src/uix/eidos/components/palabras/palabras-typography.svelte b/src/uix/eidos/components/palabras/palabras-typography.svelte
index 5692c77d5..792318612 100644
--- a/src/uix/eidos/components/palabras/palabras-typography.svelte
+++ b/src/uix/eidos/components/palabras/palabras-typography.svelte
@@ -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;
+ }
+
+{#snippet presetSelect(
+ sel: string[],
+ opts: readonly PalabrasFieldOption[],
+ onPick: (v: string | undefined) => void,
+ placeholder: string
+)}
+
+{/snippet}
+
+
+{#snippet sliderCtl(
+ val: number,
+ min: number,
+ max: number,
+ step: number,
+ onSlide: (n: number) => void,
+ label: string
+)}
+ onSlide(v[0])}
+ aria-label={label}
+ >
+
+
+
+{/snippet}
+
diff --git a/src/uix/eidos/components/palabras/palabras.css b/src/uix/eidos/components/palabras/palabras.css
index 3dc76dc19..04e36e1c5 100644
--- a/src/uix/eidos/components/palabras/palabras.css
+++ b/src/uix/eidos/components/palabras/palabras.css
@@ -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
diff --git a/src/uix/eidos/components/spin-field/spin-field.css b/src/uix/eidos/components/spin-field/spin-field.css
new file mode 100644
index 000000000..7d7f4e773
--- /dev/null
+++ b/src/uix/eidos/components/spin-field/spin-field.css
@@ -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;
+ }
+}
diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css
index e104fa7e0..1c9616845 100644
--- a/src/uix/eidos/generated/base.css
+++ b/src/uix/eidos/generated/base.css
@@ -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);
diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css
index 6fa0529f4..747d0174f 100644
--- a/src/uix/eidos/index.css
+++ b/src/uix/eidos/index.css
@@ -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';
diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts
index 58abfbdb5..b623418d2 100644
--- a/src/uix/eidos/lib/recipes/base.ts
+++ b/src/uix/eidos/lib/recipes/base.ts
@@ -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)',
diff --git a/src/uix/morfo/components/css-field.ts b/src/uix/morfo/components/css-field.ts
index 2b7097dda..798fc767c 100644
--- a/src/uix/morfo/components/css-field.ts
+++ b/src/uix/morfo/components/css-field.ts
@@ -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' }
],
diff --git a/src/uix/morfo/components/number-field.ts b/src/uix/morfo/components/number-field.ts
index 58c4f5b7d..9be9bf5e2 100644
--- a/src/uix/morfo/components/number-field.ts
+++ b/src/uix/morfo/components/number-field.ts
@@ -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' }
],
diff --git a/src/uix/soma/components/css-field/css-field-provider.svelte.ts b/src/uix/soma/components/css-field/css-field-provider.svelte.ts
index e6a212827..dfb2069eb 100644
--- a/src/uix/soma/components/css-field/css-field-provider.svelte.ts
+++ b/src/uix/soma/components/css-field/css-field-provider.svelte.ts
@@ -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: {
diff --git a/src/uix/soma/components/number-field/number-field-provider.svelte.ts b/src/uix/soma/components/number-field/number-field-provider.svelte.ts
index 9d8267378..ab18294d5 100644
--- a/src/uix/soma/components/number-field/number-field-provider.svelte.ts
+++ b/src/uix/soma/components/number-field/number-field-provider.svelte.ts
@@ -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: {
diff --git a/web/routes/uix/components/number-field/+page.svelte b/web/routes/uix/components/number-field/+page.svelte
index 8584f6cdf..dfdd7eb54 100644
--- a/web/routes/uix/components/number-field/+page.svelte
+++ b/web/routes/uix/components/number-field/+page.svelte
@@ -467,9 +467,10 @@
Triggers render a default glyph from a token, so a theme can restyle every stepper at
- once — override --number-field-control-increment-glyph /
- -decrement-glyph (and their -stacked siblings). Passing children
- to a trigger overrides the token for that instance.
+ once — override --spin-field-control-increment-glyph /
+ -decrement-glyph (and their -stacked siblings). The tokens are
+ shared by NumberField and CssField (both are spin-fields); passing children to a trigger
+ overrides the token for that instance.