diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md
index 40a05d2ba..eb55447ca 100644
--- a/src/uix/eidos/THEMING.md
+++ b/src/uix/eidos/THEMING.md
@@ -61,6 +61,7 @@
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)
---
@@ -2463,6 +2464,33 @@ 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
+
+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`).
+
+Cuatro tokens por componente, dos por layout:
+
+| 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í).
+
+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.
+
---
**Ú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 f31d9dfdb..80fc1ea6e 100644
--- a/src/uix/eidos/components/css-field/README.md
+++ b/src/uix/eidos/components/css-field/README.md
@@ -20,6 +20,12 @@ owned by Soma.
| `size` | `ResponsiveProp<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'>` | `'md'` |
| `variant` | `'surface' \| 'outline' \| 'ghost'` (`ControlVariant`) | `'surface'` |
| `color` | `ColorRole` (8 roles) | `'primary'` |
+| `steppers` | `'split' \| 'stacked'` | `'split'` |
+
+`steppers` — `split` keeps the decrement/increment at the two ends (the default);
+`stacked` collapses them into one compact column on the inline-end side (increment
+on top, decrement on the bottom), which flips to the inline-start under RTL. The
+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`, …).
@@ -31,6 +37,12 @@ Tokens live under the `css-field` recipe key in `src/uix/eidos/lib/recipes/base.
`number-field`. `[data-invalid]` tints the border with `--color-risk-border`;
`[data-focused]` / `[data-scrubbing]` raise the focus ring.
+**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` /
+`-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.
diff --git a/src/uix/eidos/components/css-field/css-field.css b/src/uix/eidos/components/css-field/css-field.css
index 53ec2d073..e057959e1 100644
--- a/src/uix/eidos/components/css-field/css-field.css
+++ b/src/uix/eidos/components/css-field/css-field.css
@@ -156,6 +156,93 @@
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],
diff --git a/src/uix/eidos/components/css-field/css-field.svelte b/src/uix/eidos/components/css-field/css-field.svelte
index 21d6a53b4..7e7f197a9 100644
--- a/src/uix/eidos/components/css-field/css-field.svelte
+++ b/src/uix/eidos/components/css-field/css-field.svelte
@@ -12,6 +12,7 @@
size = 'md',
variant = 'surface',
color = 'primary',
+ steppers = 'split',
value = $bindable(),
children,
...rest
@@ -27,6 +28,7 @@
data-size={resolvedSize}
data-variant={variant}
data-color={color}
+ data-steppers={steppers}
>
{@render children?.()}
diff --git a/src/uix/eidos/components/css-field/index.ts b/src/uix/eidos/components/css-field/index.ts
index 9cc5b3d1d..84542386a 100644
--- a/src/uix/eidos/components/css-field/index.ts
+++ b/src/uix/eidos/components/css-field/index.ts
@@ -40,5 +40,6 @@ export type {
CssFieldScrubberProps as ScrubberProps,
CssFieldSize,
CssFieldVariant,
- CssFieldColor
+ CssFieldColor,
+ CssFieldSteppers
} from './types';
diff --git a/src/uix/eidos/components/css-field/types.ts b/src/uix/eidos/components/css-field/types.ts
index 479e6e4b1..5e688edda 100644
--- a/src/uix/eidos/components/css-field/types.ts
+++ b/src/uix/eidos/components/css-field/types.ts
@@ -13,6 +13,14 @@ export type CssFieldSize = Extract;
export type CssFieldVariant = ControlVariant;
export type CssFieldColor = ColorRole;
+/**
+ * Stepper layout.
+ * - `split` — decrement and increment at the two ends, input between them.
+ * - `stacked` — both triggers in one compact column on the inline-end side
+ * (right in LTR, left in RTL): increment on top, decrement on the bottom.
+ */
+export type CssFieldSteppers = 'split' | 'stacked';
+
/**
* Props for the eidos ``.
*
@@ -27,6 +35,8 @@ export type CssFieldProps = ProviderProps & {
variant?: CssFieldVariant;
/** Accent palette for focus and scrubber active state. @default 'primary' */
color?: CssFieldColor;
+ /** Stepper layout: `split` ends or `stacked` compact column. @default 'split' */
+ steppers?: CssFieldSteppers;
};
export type CssFieldInputProps = InputProps;
diff --git a/src/uix/eidos/components/number-field/README.md b/src/uix/eidos/components/number-field/README.md
index dd599d7b3..71590cdf6 100644
--- a/src/uix/eidos/components/number-field/README.md
+++ b/src/uix/eidos/components/number-field/README.md
@@ -107,6 +107,26 @@ redondeado sin clamp en blur. No existe todavia `onValueInvalid`.
Decision: suficiente para esta fase. `onValueInvalid` y validacion sin clamp se
documentan como gap Soma futuro.
+### Steppers (`split` | `stacked`)
+
+`steppers` es perilla eidos (no morfo/soma — Soma sólo modela el spinbutton).
+
+- **`split`** (default) — decrement pegado al borde inline-start, increment al
+ inline-end, input entre ambos. Los botones son cuadrados, FLUSH al borde del
+ field (margen negativo que cancela el padding inline) con divisor en su lado
+ interior; `order` + propiedades lógicas hacen que el arreglo se invierta solo
+ bajo RTL.
+- **`stacked`** — ambos en una columna compacta en el lado inline-end (increment
+ arriba, decrement abajo), un grid 2×2 que se espeja bajo RTL. Cada flecha llena
+ media altura del field vía `align-items: stretch` — su altura es función de
+ `size` (xs..xl). Sin scrubber (no cabe).
+
+**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`
+(split, `+`/`−`) y los hermanos `…-glyph-stacked` (`▲`/`▼`). Pasar children a un
+trigger overridea el token para esa instancia.
+
## Gaps
| Gap | Disposición | Detalle |
diff --git a/src/uix/eidos/components/number-field/index.ts b/src/uix/eidos/components/number-field/index.ts
index a623c6a0a..a9a662891 100644
--- a/src/uix/eidos/components/number-field/index.ts
+++ b/src/uix/eidos/components/number-field/index.ts
@@ -39,5 +39,6 @@ export type {
NumberFieldScrubberProps as ScrubberProps,
NumberFieldSize,
NumberFieldVariant,
- NumberFieldColor
+ NumberFieldColor,
+ NumberFieldSteppers
} from './types';
diff --git a/src/uix/eidos/components/number-field/number-field.css b/src/uix/eidos/components/number-field/number-field.css
index c5eb1f2e6..6fb582167 100644
--- a/src/uix/eidos/components/number-field/number-field.css
+++ b/src/uix/eidos/components/number-field/number-field.css
@@ -6,7 +6,6 @@
display: inline-flex;
align-items: center;
- gap: var(--_number-field-gap);
inline-size: 100%;
min-inline-size: 0;
block-size: var(--_number-field-height);
@@ -14,6 +13,10 @@
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);
@@ -78,7 +81,11 @@
[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;
@@ -88,6 +95,28 @@
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;
}
@@ -141,6 +170,8 @@
[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);
}
@@ -157,6 +188,94 @@
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],
diff --git a/src/uix/eidos/components/number-field/number-field.svelte b/src/uix/eidos/components/number-field/number-field.svelte
index 94ff0f537..a235c1aa0 100644
--- a/src/uix/eidos/components/number-field/number-field.svelte
+++ b/src/uix/eidos/components/number-field/number-field.svelte
@@ -12,6 +12,7 @@
size = 'md',
variant = 'surface',
color = 'primary',
+ steppers = 'split',
value = $bindable(),
children,
...rest
@@ -27,6 +28,7 @@
data-size={resolvedSize}
data-variant={variant}
data-color={color}
+ data-steppers={steppers}
>
{@render children?.()}
diff --git a/src/uix/eidos/components/number-field/types.ts b/src/uix/eidos/components/number-field/types.ts
index 28a836f00..9d176eb31 100644
--- a/src/uix/eidos/components/number-field/types.ts
+++ b/src/uix/eidos/components/number-field/types.ts
@@ -12,6 +12,15 @@ export type NumberFieldSize = Extract;
export type NumberFieldVariant = ControlVariant;
export type NumberFieldColor = ColorRole;
+/**
+ * Stepper layout.
+ * - `split` — decrement flush to the inline-start edge, increment flush to the
+ * inline-end edge, input between them (the classic stepper).
+ * - `stacked` — both triggers in one compact column on the inline-end side
+ * (right in LTR, left in RTL): increment on top, decrement on the bottom.
+ */
+export type NumberFieldSteppers = 'split' | 'stacked';
+
/**
* Props for the eidos ``.
*
@@ -25,6 +34,8 @@ export type NumberFieldProps = ProviderProps & {
variant?: NumberFieldVariant;
/** Accent palette for focus and scrubber active state. @default 'primary' */
color?: NumberFieldColor;
+ /** Stepper layout: `split` ends or `stacked` compact column. @default 'split' */
+ steppers?: NumberFieldSteppers;
};
export type NumberFieldInputProps = InputProps;
diff --git a/src/uix/eidos/components/palabras/palabras-handle.svelte b/src/uix/eidos/components/palabras/palabras-handle.svelte
index 11024bc98..fba75f9eb 100644
--- a/src/uix/eidos/components/palabras/palabras-handle.svelte
+++ b/src/uix/eidos/components/palabras/palabras-handle.svelte
@@ -1,31 +1,21 @@
{#if grip}
-
+
-
-
- 1 ? '' : undefined}
- data-words-external-tool
- aria-label="Acciones del bloque"
- draggable={grip.draggable}
- onpointerdown={onTriggerPointerDown}
- ondragstart={onDragStart}
- >
- {#snippet icon()}{/snippet}
- Acciones del bloque
-
-
-
-
-
-
-
- Propiedades
-
-
-
-
-
- Insertar bloque
-
-
- {#each insertTypes as bt (bt.id)}
- {@const Icon = bt.icon}
- doInsertType(bt)}>
-
- {bt.label}
-
- {/each}
-
-
-
-
- Mover arriba
-
- = blockCount - 1}
- >
-
- Mover abajo
-
-
-
-
- Eliminar bloque
-
- {#if inColumn}
-
-
-
- Seleccionar columnas
-
-
-
- Eliminar columna
-
- {/if}
-
-
-
+
+
{/if}
diff --git a/src/uix/eidos/components/palabras/palabras-panel.svelte b/src/uix/eidos/components/palabras/palabras-panel.svelte
index c57b99c03..6440bdd66 100644
--- a/src/uix/eidos/components/palabras/palabras-panel.svelte
+++ b/src/uix/eidos/components/palabras/palabras-panel.svelte
@@ -11,20 +11,29 @@
* `setBlockVisualAtPath` (style, `field.visual`). The `textarea` field edits
* the block's TEXT (its inline `children`). Controls are FRAMEWORK components.
*/
- import { sanitizeWordsUrl, type ProviderSnippetProps } from '$soma/components/words';
+ import {
+ sanitizeWordsUrl,
+ type ProviderSnippetProps,
+ type WordsSpacing
+ } from '$soma/components/words';
import { CssField } from '$uix/eidos/components/css-field';
import { Switch } from '$uix/eidos/components/switch';
import { Slider } from '$uix/eidos/components/slider';
import { NumberField } from '$uix/eidos/components/number-field';
import { Tabs } from '$uix/eidos/components/tabs';
+ import { Accordion } from '$uix/eidos/components/accordion';
import { TextArea } from '$uix/eidos/components/textarea';
import { Field } from '$uix/eidos/components/field';
import { Button } from '$uix/eidos/components/button';
- import { Trash2, Upload } from '$uix/eidos/components/icon';
- import { getPanelSchema } from './panel-schema';
- import { blockTypeIcon } from './palabras-block-types';
+ import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
+ import { Trash2, Upload, ArrowBigUp, ArrowBigDown, LayersPlus } from '$uix/eidos/components/icon';
+ import { getPanelSchema, PALABRAS_FONTS } from './panel-schema';
+ import { blockTypeIcon, insertableBlockTypes, blockToInsert } from './palabras-block-types';
+ import type { PalabrasBlockType } from './palabras-block-types';
import PalabrasSegmented from './palabras-segmented.svelte';
import PalabrasColorRow from './palabras-color-row.svelte';
+ import PalabrasSpacing from './palabras-spacing.svelte';
+ import PalabrasTypography from './palabras-typography.svelte';
import { pickImageFile } from '$uix/eidos/components/words/words-image-file';
import { FloatPanel } from '$uix/eidos/components/float-panel';
import { deleteBlockAndReanchor } from './palabras-block-actions';
@@ -64,31 +73,30 @@
return content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`) as HTMLElement | null;
});
- // FloatPanel is a FREE window once open (drag + resize). FloatPanel seeds the
- // position from `anchor` on the first open; when the active block CHANGES while
- // the panel stays open, re-anchor next to the new block (right, centred on its
- // mid-height, viewport-clamped) so the panel follows the selection. A drag
- // within one block is preserved — we only reposition when the block id changes.
- // Cleared on close so the next open re-seeds from the anchor.
+ // FloatPanel is a FREE window once open (drag + resize). We OWN its position:
+ // on open — and whenever the active block changes — we anchor it to the RIGHT of
+ // the block, centred on its mid-height, viewport-clamped. A drag within one block
+ // is preserved (we only reposition when the block id changes).
+ //
+ // NEVER clear `panelPos` while the panel is mounted. Setting it to `undefined` on
+ // close snapped the FloatPanel to 0,0 (left edge) for the whole exit animation —
+ // the panel visibly "redrew on the left" then faded. On close we only re-arm the
+ // anchoring (posBlock = null) and leave the last position so the close animates
+ // in place; the next open re-anchors from the block.
const PANEL_W = 288;
const PANEL_H = 380;
let panelPos = $state<{ x: number; y: number } | undefined>(undefined);
let posBlock: string | null = null;
$effect(() => {
if (!open) {
- panelPos = undefined;
- posBlock = null;
+ posBlock = null; // re-arm anchoring for the next open; keep panelPos (no close-jump)
return;
}
const el = anchorEl;
const win = content?.ownerDocument?.defaultView;
const id = api.activeBlockId;
if (!el || !win || !id) return;
- if (posBlock === null) {
- posBlock = id; // first open — FloatPanel's own anchor seed positions it
- return;
- }
- if (id === posBlock) return; // same block — don't fight a drag
+ if (id === posBlock) return; // already anchored for this block — don't fight a drag
posBlock = id;
const r = el.getBoundingClientRect();
const gap = 16;
@@ -102,10 +110,26 @@
// Three fixed tabs (Contenido open by default). Their fields come from the
// block's schema; Diseño / Avanzado may be empty for now.
const contenidoFields = $derived(schema?.contenido ?? []);
- const disenoFields = $derived(schema?.diseño ?? []);
+ const disenoSections = $derived(schema?.diseño ?? []);
const avanzadoFields = $derived(schema?.avanzado ?? []);
let tab = $state('contenido');
+ // Diseño accordion open state. Seeded from each section's `defaultCollapsed`
+ // (Disposición opens by default); re-seeded when the block TYPE changes so a new
+ // block gets its defaults, while preserving the user's open/close within a type.
+ let designOpen = $state([]);
+ let designSeedType: string | undefined;
+ $effect(() => {
+ const type = loc?.node?.type;
+ if (type === designSeedType) return;
+ designSeedType = type;
+ // Single-accordion: open ONLY the first non-collapsed section (Disposición).
+ designOpen = disenoSections
+ .filter((s) => !s.defaultCollapsed)
+ .slice(0, 1)
+ .map((s) => s.title);
+ });
+
/** Plain text of the block's inline `children` — for the Texto textarea. */
function blockText(): string {
const ch = (loc?.node as { children?: readonly unknown[] } | undefined)?.children;
@@ -158,7 +182,10 @@
/** Write a field's value, routing by `field.visual`: a STYLE override goes to
* `setBlockVisualAtPath` (the engine's STYLE_KEYS), a STRUCTURAL prop to
* `updateBlockAtPath`. The single panel now hosts both kinds. */
- function commit(field: PalabrasFieldDef, value: string | number | boolean | undefined) {
+ function commit(
+ field: PalabrasFieldDef,
+ value: string | number | boolean | WordsSpacing | undefined
+ ) {
const path = loc?.path;
if (!path) return;
if (field.visual) {
@@ -167,9 +194,45 @@
api.applyCommand({ type: 'updateBlockAtPath', blockPath: path, patch: { [field.key]: value } });
}
}
+ /** Apply a MULTI-key visual patch (the typography control writes several STYLE_KEYS
+ * at once). `undefined` values clear their prop back to the block default. */
+ function commitVisualPatch(patch: Record) {
+ const path = loc?.path;
+ if (!path) return;
+ api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual: patch });
+ }
+ /** The active block's typographic visual subset — fed to . */
+ function typoValue() {
+ const n = loc?.node as
+ | {
+ fontFamily?: string;
+ fontStyle?: 'normal' | 'italic' | 'oblique';
+ strikethrough?: boolean;
+ fontSize?: number;
+ fontWeight?: number;
+ lineHeight?: number;
+ letterSpacing?: string;
+ wordSpacing?: string;
+ textWrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty';
+ maxWidth?: string;
+ tabularNumbers?: boolean;
+ }
+ | undefined;
+ return n ?? {};
+ }
function activeEl(): HTMLElement | null {
return (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null;
}
+ /** The active block's DEFAULT font-size (px) — its COMPUTED size, which (while
+ * `fontSize` isn't overridden) is the heading level's own size. Feeds the Tamaño
+ * control so it rests at the selected H's size instead of an empty `auto`. */
+ function blockDefaultPx(): number | undefined {
+ void api.html; // re-read when the document (and the active block's level) changes
+ const el = activeEl();
+ if (!el) return undefined;
+ const fs = parseFloat(getComputedStyle(el).fontSize);
+ return Number.isFinite(fs) ? Math.round(fs) : undefined;
+ }
/** Display colour for a `color` field: the stored override hex, else the block's
* COMPUTED colour (so the swatch reflects the effective value, like Apariencia did). */
function effectiveColor(field: PalabrasFieldDef): string | undefined {
@@ -180,14 +243,30 @@
const cs = getComputedStyle(el);
return field.key === 'background' ? cs.backgroundColor : cs.color;
}
+ /** Active pill value (string). The custom pill (e.g. `HC`) wins when its
+ * `customWhen` prop is overridden on the block; otherwise the stored value. */
+ function pillsActive(field: PalabrasFieldDef): string | undefined {
+ if (field.customOption != null && field.customWhen && loc?.node?.[field.customWhen] != null) {
+ return String(field.customOption);
+ }
+ const v = effective(field);
+ return v == null ? undefined : String(v);
+ }
function onPills(field: PalabrasFieldDef, v: string | undefined) {
// Deselecting (re-click the active item) clears the prop → restores the default.
if (v == null) {
commit(field, undefined);
return;
}
+ // The custom (HC) pill is an INDICATOR — picking it is a no-op.
+ if (field.customOption != null && v === String(field.customOption)) return;
const opt = field.options?.find((o) => String(o.value) === v);
- if (opt) commit(field, opt.value);
+ if (opt) {
+ commit(field, opt.value);
+ // Picking a REAL option clears the override that drives the custom pill
+ // (choosing H2 drops the size override → back to H2's own default size).
+ if (field.customWhen) commitVisualPatch({ [field.customWhen]: undefined });
+ }
}
function onNumberChange(field: PalabrasFieldDef, v: number | undefined) {
// clearWhenZero (width / height): empty or ≤0 clears the prop. Otherwise store
@@ -241,16 +320,57 @@
// Close the panel on delete — the block it was inspecting is gone.
onClose();
}
+
+ // ── Footer actions (move / insert) — path-aware, the same the gutter grip used.
+ // They act on the ACTIVE block (`loc`), so the panel is now the single home for
+ // every per-block action (delete stays in the header). Index / count come from
+ // the active block element's siblings, matching how the grip computed them.
+ const sibInfo = $derived.by(() => {
+ const el = anchorEl;
+ if (!el?.parentElement) return { index: -1, count: 0 };
+ const sibs = Array.from(el.parentElement.children).filter(
+ (c): c is HTMLElement => c instanceof HTMLElement && c.hasAttribute('data-words-id')
+ );
+ return { index: sibs.indexOf(el), count: sibs.length };
+ });
+ const canMoveUp = $derived(sibInfo.index > 0);
+ const canMoveDown = $derived(sibInfo.index >= 0 && sibInfo.index < sibInfo.count - 1);
+
+ function doMoveUp() {
+ if (!loc || !canMoveUp) return;
+ api.applyCommand({ type: 'moveBlockAtPath', blockPath: loc.path, direction: 'up' });
+ }
+ function doMoveDown() {
+ if (!loc || !canMoveDown) return;
+ api.applyCommand({ type: 'moveBlockAtPath', blockPath: loc.path, direction: 'down' });
+ }
+
+ // Insertable types (columns are a top-level layout primitive — never offered when
+ // the active block is nested). Insert a fresh block as a sibling RIGHT AFTER the
+ // active one, then follow it (its id is assigned up front; the engine keeps it).
+ const insertTypes = $derived(
+ loc && loc.path.length > 1
+ ? insertableBlockTypes().filter((t) => t.id !== 'columns')
+ : insertableBlockTypes()
+ );
+ function doInsertType(bt: PalabrasBlockType) {
+ if (!loc) return;
+ const path = loc.path;
+ const blockPath = [...path.slice(0, -1), path[path.length - 1] + 1];
+ const id = crypto.randomUUID();
+ api.applyCommand({ type: 'insertBlockAtPath', blockPath, block: { ...blockToInsert(bt), id } });
+ const parentSegs = (api.activeBlockId ?? '').split('/').slice(0, -1);
+ api.setActiveBlock([...parentSegs, id].join('/'));
+ }
{#snippet fieldControl(field: PalabrasFieldDef)}
{@const disabled = field.gatedBy ? !!valueOf(field.gatedBy) : false}
{#if field.type === 'pills'}
- {@const pv = effective(field)}
{field.label}{field.label}
@@ -468,6 +473,72 @@
{/each}
+
+
+
+
+ eidos stepper layout · steppers
+
+
+ split (default) — decrement/increment at the two ends. stacked —
+ one compact column on the inline-end (▲ top / ▼ bottom); flips to the inline-start under
+ RTL. The arrow glyphs come from themeable tokens, so passing children is optional.
+
+
+
+
+ split
+
+
+
+
+
+
+
+ stacked
+
+
+
+
+
+
+
+ stacked · rtl
+
+
+
+
+
+
diff --git a/web/routes/uix/components/number-field/+page.svelte b/web/routes/uix/components/number-field/+page.svelte
index 83cfecdfe..8584f6cdf 100644
--- a/web/routes/uix/components/number-field/+page.svelte
+++ b/web/routes/uix/components/number-field/+page.svelte
@@ -3,6 +3,7 @@
NumberField,
type NumberFieldColor,
type NumberFieldSize,
+ type NumberFieldSteppers,
type NumberFieldVariant
} from '$uix/eidos/components/number-field';
import { compileMorfo } from '$uix/morfo';
@@ -35,6 +36,8 @@
let size = $state('md');
let variant = $state('surface');
let color = $state('primary');
+ let steppers = $state('split');
+ let stepperLayoutValue = $state(8);
let trace = $state([]);
let stageRef = $state(null);
@@ -249,9 +252,9 @@
'' + 'script>',
'',
``,
- ' -',
+ ' ',
' ',
- ' +',
+ ' ',
' drag',
''
].join('\n')
@@ -275,10 +278,11 @@
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
+ steppers !== 'split' && ` steppers="${steppers}"`,
'>',
- ' -',
+ ' ',
' ',
- ' +',
+ ' ',
' drag',
''
]
@@ -325,10 +329,11 @@
{size}
{variant}
{color}
+ {steppers}
>
- -
+
- +
+ drag
+ split (default) — decrement/increment flush at the two ends.
+ stacked — one compact column on the inline-end (▲ top / ▼ bottom); flips to
+ the inline-start under RTL.
+
+
+
+
+ split
+
+
+
+
+
+
+
+ stacked
+
+
+
+
+
+
+
+ stacked · rtl
+
+
+
+
+
+
+
+
+
+ The stacked column height tracks the field size — each arrow fills half the
+ field height, so it grows from xs to xl.
+
+
+ {#each ['xs', 'sm', 'md', 'lg', 'xl'] as fieldSize}
+
+ {fieldSize}
+
+
+
+
+
+
+ {/each}
+
+
+
+ eidos stepper glyphs · themeable
+
+
+ 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.
+