feat(eidos): themeable stepper glyphs + css-field compact steppers

NumberField and CssField steppers gain a `stacked` compact layout and
themeable default glyphs.

- Glyphs render from recipe tokens via `:empty::before` (split plus/minus,
  stacked up/down triangles); passing children overrides per instance,
  a theme overrides the token. 4 tokens per component, unicode-escaped.
- CssField gains the `steppers` prop (split | stacked); its stacked layout
  mirrors NumberField (self-contained — the split layout left untouched).
- NumberField split: square buttons flush to the field edge with an inner
  divider and higher hover/focus contrast (control-radius 0).
- Stacked rows use minmax(0,1fr) + a height-proportional glyph so the two
  arrows stay symmetric and uncut at xs.
- Demos: steppers controls + stepper-layout / sizes / themed-glyph
  showcases; code snippets use the themed defaults.
- Docs: THEMING section 33, number-field and css-field READMEs.

Note: also sweeps in the pre-existing palabras WIP already in the tree.

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

@ -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

@ -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.

@ -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],

@ -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?.()}
</CssField.Provider>

@ -40,5 +40,6 @@ export type {
CssFieldScrubberProps as ScrubberProps,
CssFieldSize,
CssFieldVariant,
CssFieldColor
CssFieldColor,
CssFieldSteppers
} from './types';

@ -13,6 +13,14 @@ export type CssFieldSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
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 `<CssField>`.
*
@ -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;

@ -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 |

@ -39,5 +39,6 @@ export type {
NumberFieldScrubberProps as ScrubberProps,
NumberFieldSize,
NumberFieldVariant,
NumberFieldColor
NumberFieldColor,
NumberFieldSteppers
} from './types';

@ -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],

@ -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?.()}
</NumberField.Provider>

@ -12,6 +12,15 @@ export type NumberFieldSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
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 `<NumberField>`.
*
@ -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;

@ -1,31 +1,21 @@
<script lang="ts">
/**
* Block grip — trigger for the block ACTION MENU + drag-handle.
* Block grip — a drag-handle that opens the properties panel.
*
* PATH-UNIFORM (Notion-style inline handle): the grip anchors to ANY block at
* ANY nesting depth. A top-level block gets its grip in the left RAIL; a block
* INSIDE a column gets its grip at the block's own left edge (floating out of
* the rail into the canvas). Every action routes through the engine's
* path-aware commands (`moveBlockAtPath` / `insertBlockAtPath` /
* `deleteBlockAtPath`) addressing the block's full numeric `WordsPath` —
* resolved from its materialized id-path via `findBlockById`.
* PATH-UNIFORM (Notion-style inline handle): the grip anchors to ANY block at ANY
* nesting depth. A top-level block gets its grip in the left RAIL; a block INSIDE a
* column gets its grip at the block's own left edge (floating out of the rail into
* the canvas).
*
* Two behaviours from the same trigger, by the browser's click-vs-drag:
* - CLICK → opens the ACTION MENU (Propiedades · Insertar bloque · Mover
* arriba/abajo · Eliminar). A drag fires no trailing click, so a reorder
* never opens it.
* - DRAG → reorders (HTML5 drag; the canvas `<WordsBlockDrag>` is the sink).
* Drag is enabled ONLY for top-level blocks — the drop sink is top-level.
* - CLICK → opens the properties PANEL directly. Every per-block action — move /
* insert / delete — now lives in the panel itself (its footer + header), so the
* grip no longer opens an intermediate menu: one click instead of two.
* - DRAG → reorders (HTML5 drag; the canvas `<WordsBlockDrag>` is the sink). Drag
* is enabled ONLY for top-level blocks — the drop sink is top-level.
*
* TWO open/close loops had to be killed, both seen as the menu flickering:
* 1. EDITOR ↔ menu — opening the menu steals focus from the contenteditable;
* without a marker the editor reads the blur as external, re-grabs focus,
* and the menu closes → reopens. Fix: the trigger and the portaled menu
* BOTH carry `data-words-external-tool`, so the provider's
* `isInsideWordsTool` treats focus landing here as internal.
* 2. GRIP ↔ menu — the menu's `disabled` flags must NOT re-derive from
* `api.html`. The grip captures index/count at placement and is frozen
* while the menu is open. Mirrors Words' stable-`index`.
* The trigger carries `data-words-external-tool` so the provider's
* `isInsideWordsTool` treats focus landing here as internal (no editor churn).
*
* Grip placement: PINNED to the active block (always ⋮). It moves only when the
* active block changes (click / Arrow nav / breadcrumb) — never on hover, which
@ -35,22 +25,9 @@
import type { ActiveDom } from '$adom';
import { findBlockById, type ProviderSnippetProps } from '$soma/components/words';
import { Box } from '$uix/eidos/components/box';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import {
EllipsisVertical,
Settings2,
LayersPlus,
ArrowBigUp,
ArrowBigDown,
Trash2,
Columns2
} from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import { EllipsisVertical } from '$uix/eidos/components/icon';
import { WORDS_BLOCK_DRAG_TYPE } from '$uix/eidos/components/words/words-block-drag-type';
import {
blockToInsert,
insertableBlockTypes,
type PalabrasBlockType
} from './palabras-block-types';
let {
api,
@ -67,34 +44,30 @@
onOpen: (id: string) => void;
onClose: () => void;
} = $props();
// `dom` / `panelOpen` / `onClose` are part of the slot's contract (kept so the
// parent's call is unchanged) but the grip no longer needs them — every action
// moved into the panel. Referenced here so they don't read as dead props.
void dom;
void panelOpen;
void onClose;
// Px the inline handle sits to the LEFT of a NESTED block (top-level blocks
// keep their handle flush in the rail at left:0). Must be > the handle's own
// width so it clears the block's text, and the columns `gap` (palabras.css)
// must exceed it so the handle lands cleanly in the inter-column gutter —
// never overlapping the previous column (which would re-anchor on hover).
// Px the inline handle sits to the LEFT of a NESTED block (top-level blocks keep
// their handle flush in the rail at left:0). Must be > the handle's own width so
// it clears the block's text, and the columns `gap` (palabras.css) must exceed it.
const HANDLE_REACH = 30;
// The grip carries its block's full id-path + numeric path + position-in-parent
// (index / count) CAPTURED at placement time (not reactively re-read). Freezing
// these is the churn fix: opening the menu moves focus → provider re-renders →
// re-deriving from `api.html` would re-create the focused item and loop the
// menu open/close. `draggable` is true only for top-level blocks (drag sink is
// top-level). `top`/`left` are host-relative (the slot lives in the rail, whose
// origin coincides with the doc's, so host coords map straight onto it).
// The grip carries its block's full id-path + numeric path + position-in-parent,
// captured at placement. `draggable` is true only for top-level blocks (drag sink
// is top-level). `top`/`left` are host-relative (the slot lives in the rail).
type Grip = {
top: number;
left: number;
id: string;
path: readonly number[];
index: number;
count: number;
draggable: boolean;
};
let grip = $state<Grip | null>(null);
let menuOpen = $state(false);
// LOCAL reads on elements we own — left inline per ADom doctrine.
/** Full materialized id-path of a block element: the `/`-joined chain of every
* `data-words-id` ancestor (root → block). Matches `findBlockById`'s addressing. */
function idPathOf(target: HTMLElement, host: HTMLElement): string {
@ -124,28 +97,17 @@
}
return null;
}
/** Nearest preceding/following SIBLING that is a block (has `data-words-id`). */
function blockSibling(el: HTMLElement, dir: 'prev' | 'next'): HTMLElement | null {
let s = dir === 'prev' ? el.previousElementSibling : el.nextElementSibling;
while (s && !(s instanceof HTMLElement && s.hasAttribute('data-words-id'))) {
s = dir === 'prev' ? s.previousElementSibling : s.nextElementSibling;
}
return s instanceof HTMLElement ? s : null;
}
/** Top-level index of a block (or of its top-level ancestor) — `path` fallback. */
function topIndexOf(block: HTMLElement): number {
let el: HTMLElement = block;
while (el.parentElement && el.parentElement !== content) el = el.parentElement;
return topLevelBlocks().indexOf(el);
}
// Build the grip for a block: position (host-relative), full id-path, numeric
// path (from the model via `findBlockById`), and its index/count among its
// DIRECT block siblings (the parent container's blocks).
// Build the grip for a block: position (host-relative), full id-path, numeric path,
// and whether it's draggable (top-level only).
function gripFor(block: HTMLElement, h: HTMLElement): Grip {
// A LIST is gripped as ONE unit — never an individual item. Clicking inside a
// list activates the item (its caret lands there), but the grip + its menu
// must target the whole list block (and sit at the list's left edge, not
// indented inside it). Climb from a list item to its list block.
// A LIST is gripped as ONE unit — never an individual item. Climb from a list
// item to its list block (the grip targets the whole list, at its left edge).
if (block.getAttribute('data-words-node') === 'list-item') {
const listBlock = block.closest('[data-words-block]');
if (listBlock instanceof HTMLElement) block = listBlock;
@ -155,263 +117,68 @@
const id = idPathOf(block, h);
const loc = findBlockById(api.document, id);
const path = loc?.path ?? [topIndexOf(block)];
const parentEl = block.parentElement;
const sibs = parentEl
? Array.from(parentEl.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-words-id')
)
: [block];
const isTop = path.length <= 1;
return {
top: br.top - hr.top + br.height / 2,
// Top-level → flush in the rail. Nested → just left of the block, the
// handle floating out of the rail into the canvas (rail has no overflow
// clip, so it spills over the columns).
// Top-level → flush in the rail. Nested → just left of the block, floating
// out of the rail into the canvas.
left: isTop ? 0 : Math.round(br.left - hr.left - HANDLE_REACH),
id,
path,
index: Math.max(0, sibs.indexOf(block)),
count: sibs.length,
draggable: isTop
};
}
// Index + count of the grip's block among its siblings — read from the FROZEN
// grip, NOT from `api.html`. Drives the move up/down disabled flags. The grip
// stays put while the menu is open, so these are stable for the menu's whole
// lifetime.
const blockIndex = $derived(grip?.index ?? -1);
const blockCount = $derived(grip?.count ?? 0);
// Whether the active block lives inside a top-level COLUMN — gates the
// "Eliminar columna" action (the column has no button of its own once it holds
// blocks, so deleting it must live in the block's grip menu).
const inColumn = $derived.by(
() =>
!!grip && grip.path.length >= 3 && api.document.children[grip.path[0]]?.type === 'columns'
);
// PINNED to the active block — the grip shows ⋮ and moves ONLY when the active
// block changes (a click → WordsActivate, Arrow nav, or a breadcrumb crumb). It
// does NOT follow the cursor on hover — that was erratic and made it impossible
// to grip the right level. FROZEN while the menu is open.
// does NOT follow the cursor on hover.
$effect(() => {
const h = host();
const activeId = api.activeBlockId;
void api.html;
const mo = menuOpen;
if (!h || mo) return;
if (!h) return;
const activeEl = activeId ? findElByIdPath(activeId) : null;
untrack(() => {
grip = activeEl ? gripFor(activeEl, h) : null;
});
});
// Re-pressing the grip WHILE THE MENU IS OPEN must close it cleanly. The menu's
// Dismissal layer treats any pointerdown OUTSIDE the menu content as an
// outside-dismiss — and the grip (the trigger) is NOT inside the menu, so it
// counts as "outside": close on pointerdown, then the trailing click re-opens
// = flicker. Stopping propagation only while open keeps the pointerdown from
// reaching the Dismissal, so the click is the single source of the toggle.
function onTriggerPointerDown(e: PointerEvent) {
if (menuOpen) e.stopPropagation();
}
// HTML5 drag — top-level blocks ONLY (the drop sink reorders top-level). Sets
// the canonical MIME + effectAllowed; no preventDefault, no state writes.
function onDragStart(e: DragEvent) {
if (!grip || !grip.draggable || !e.dataTransfer) return;
e.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(grip.path[0]));
e.dataTransfer.effectAllowed = 'move';
}
// ── Menu actions (all path-aware) ─────────────────────────────────────────
// CLICK → open the properties panel on this block.
function openProperties() {
if (!grip) return;
api.setActiveBlock(grip.id);
onOpen(grip.id);
}
function doMoveUp() {
if (!grip || blockIndex <= 0) return;
api.setActiveBlock(grip.id);
api.applyCommand({ type: 'moveBlockAtPath', blockPath: grip.path, direction: 'up' });
}
function doMoveDown() {
if (!grip || blockIndex < 0 || blockIndex >= blockCount - 1) return;
api.setActiveBlock(grip.id);
api.applyCommand({ type: 'moveBlockAtPath', blockPath: grip.path, direction: 'down' });
}
// Sibling slot right after the grip's block, in the SAME container.
function insertSiblingPath(g: Grip): readonly number[] {
return [...g.path.slice(0, -1), g.path[g.path.length - 1] + 1];
}
// Insertable block types (registry-derived, static) for the "Insertar bloque"
// submenu grid. `blockToInsert` builds the raw block; the engine normalises it.
const blockTypes = insertableBlockTypes();
// Columns are a TOP-LEVEL layout primitive — never offered when the gripped
// block is NESTED (inside a column / cell / callout). Mirrors the per-column
// "+" filter and the engine's no-columns-in-columns guard.
const insertTypes = $derived(
grip && grip.path.length > 1 ? blockTypes.filter((t) => t.id !== 'columns') : blockTypes
);
function doInsertType(bt: PalabrasBlockType) {
if (!grip) return;
const blockPath = insertSiblingPath(grip);
// Assign the id UP FRONT so we can re-point the active block (and the grip)
// onto the NEW block the instant it's inserted — the engine preserves a
// provided id. For text blocks the engine also carries a caret into the new
// block (editable immediately); for image / divider / table / callout there
// is no caret, so this explicit activate is what makes the inspector follow.
// Image inserts a PLACEHOLDER (`{ type: 'image', src: '' }`); its URL / alt
// are filled from the properties panel afterwards — no file picker on insert.
const id = crypto.randomUUID();
api.applyCommand({
type: 'insertBlockAtPath',
blockPath,
block: { ...blockToInsert(bt), id }
});
// New block's materialized id-path: the gripped block's parent prefix + new id
// (it's inserted as a sibling, so they share the parent container).
const parentSegs = grip.id.split('/').slice(0, -1);
api.setActiveBlock([...parentSegs, id].join('/'));
}
// On close, land the caret in the editor — for an inserted text block the engine
// carried the model selection INTO it, so `restoreCaret` makes it editable
// without a click. `preventDefault` stops the menu pulling focus back to the
// grip trigger (which would leave the new block unfocused). Mirrors the column
// inserter's close handler.
function onMenuCloseAutoFocus(e: Event) {
e.preventDefault();
dom.requestFrame(() => api.restoreCaret(), content);
}
function doDelete() {
if (!grip) return;
const h = host();
const el = findElByIdPath(grip.id);
// Re-point the active block to a surviving SIBLING (read from the pre-delete
// DOM; the survivor's id-path is stable across the re-render). No sibling →
// clear (an emptied column falls back to its own affordance).
let survivorId: string | null = null;
if (el && h) {
const sib = blockSibling(el, 'prev') ?? blockSibling(el, 'next');
survivorId = sib ? idPathOf(sib, h) : null;
}
api.applyCommand({ type: 'deleteBlockAtPath', blockPath: grip.path });
api.setActiveBlock(survivorId);
onClose();
}
// Select the whole COLUMNS block (the container) — the only reliable way to grip
// it once its columns hold blocks (clicking inside always lands on the inner
// block). Its id is the FIRST segment of the active block's materialized path.
function doSelectColumns() {
if (!grip || grip.path.length < 3) return;
const columnsId = grip.id.split('/')[0];
if (columnsId) api.setActiveBlock(columnsId);
}
// Delete the WHOLE column the active block sits in (only shown when `inColumn`).
function doDeleteColumn() {
if (!grip || grip.path.length < 3) return;
api.applyCommand({ type: 'deleteColumn', columnsIdx: grip.path[0], colIdx: grip.path[1] });
// The column's blocks are gone (and the columns block may have unwrapped) —
// clear the active block; the user re-activates by clicking a survivor.
api.setActiveBlock(null);
onClose();
// HTML5 drag — top-level blocks ONLY. Sets the canonical MIME + effectAllowed.
function onDragStart(e: DragEvent) {
if (!grip || !grip.draggable || !e.dataTransfer) return;
e.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(grip.path[0]));
e.dataTransfer.effectAllowed = 'move';
}
</script>
{#if grip}
<!-- Positioned slot: owns the gutter/inline placement. `top` + `left` are
host-relative inline (the slot lives in the rail, whose origin coincides
with the doc's). -->
<!-- Positioned slot: owns the gutter/inline placement (host-relative inline). -->
<Box data-palabras-handle-slot style="top: {grip.top}px; left: {grip.left}px">
<DropdownMenu bind:open={menuOpen}>
<!-- Trigger = the framework <Button> (ghost/xs/iconOnly), a drag source
(draggable + ondragstart) for top-level blocks. `data-words-external-tool`
marks it as part of the editor's focus scope so opening the menu doesn't loop. -->
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
rounded="full"
data-palabras-handle
data-nested={grip.path.length > 1 ? '' : undefined}
data-words-external-tool
aria-label="Acciones del bloque"
draggable={grip.draggable}
onpointerdown={onTriggerPointerDown}
ondragstart={onDragStart}
>
{#snippet icon()}<EllipsisVertical />{/snippet}
Acciones del bloque
</DropdownMenu.Trigger>
<!-- Portal the menu to <body>: the grip slot has `transform: translateY(-50%)`,
which makes it the containing block for `position: fixed` descendants.
Portaling escapes the transform so floating-ui positioning + collision
are viewport-correct. -->
<DropdownMenu.Portal>
<!-- `preventScroll={false}`: a block-action menu must NOT lock page scroll
(its ScrollLock toggles the scrollbar = the visible flicker/jump).
`data-words-external-tool`: focus-scope marker on the portaled menu. -->
<DropdownMenu.Content
side="bottom"
align="start"
preventScroll={false}
onCloseAutoFocus={onMenuCloseAutoFocus}
data-palabras-handle-menu
data-words-external-tool
>
<DropdownMenu.Item onSelect={openProperties}>
<Settings2 />
<span>Propiedades</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<LayersPlus />
<span>Insertar bloque</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
data-palabras-handle-menu
data-words-external-tool
data-palabras-insert-grid
>
{#each insertTypes as bt (bt.id)}
{@const Icon = bt.icon}
<DropdownMenu.Item onSelect={() => doInsertType(bt)}>
<Icon />
<span>{bt.label}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item onSelect={doMoveUp} disabled={blockIndex <= 0}>
<ArrowBigUp />
<span>Mover arriba</span>
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={doMoveDown}
disabled={blockIndex < 0 || blockIndex >= blockCount - 1}
>
<ArrowBigDown />
<span>Mover abajo</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={doDelete}>
<Trash2 />
<span>Eliminar bloque</span>
</DropdownMenu.Item>
{#if inColumn}
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={doSelectColumns}>
<Columns2 />
<span>Seleccionar columnas</span>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={doDeleteColumn}>
<Trash2 />
<span>Eliminar columna</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<!-- The grip = a draggable framework <Button>. CLICK opens the properties panel
directly (its footer + header hold every per-block action — no menu). DRAG
(top-level only) reorders. `data-words-external-tool` keeps focus landing
here inside the editor's focus scope. -->
<Button
variant="ghost"
size="xs"
iconOnly
rounded="full"
data-palabras-handle
data-nested={grip.path.length > 1 ? '' : undefined}
data-words-external-tool
aria-label="Propiedades del bloque"
title="Propiedades del bloque"
draggable={grip.draggable}
ondragstart={onDragStart}
onclick={openProperties}
>
{#snippet icon()}<EllipsisVertical />{/snippet}
</Button>
</Box>
{/if}

@ -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<string[]>([]);
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<string, unknown>) {
const path = loc?.path;
if (!path) return;
api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual: patch });
}
/** The active block's typographic visual subset — fed to <PalabrasTypography>. */
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('/'));
}
</script>
{#snippet fieldControl(field: PalabrasFieldDef)}
{@const disabled = field.gatedBy ? !!valueOf(field.gatedBy) : false}
{#if field.type === 'pills'}
{@const pv = effective(field)}
<div data-palabras-field data-disabled={disabled ? '' : undefined}>
<span data-palabras-field-label>{field.label}</span>
<PalabrasSegmented
value={pv == null ? undefined : String(pv)}
value={pillsActive(field)}
options={field.options ?? []}
{disabled}
deselectable={!field.required}
@ -329,7 +449,7 @@
<div data-palabras-field>
<span data-palabras-field-label>{field.label}</span>
<TextArea
size="sm"
size="xs"
value={blockText()}
onValueChange={(v) => writeBlockText(v)}
autosize
@ -342,7 +462,7 @@
{:else if field.type === 'text'}
<div data-palabras-field>
<span data-palabras-field-label>{field.label}</span>
<Field size="sm" variant="surface">
<Field size="xs" variant="surface">
<Field.Control>
<Field.Input
type="text"
@ -372,6 +492,23 @@
</Field.Control>
</Field>
</div>
{:else if field.type === 'spacing'}
<div data-palabras-field>
<span data-palabras-field-label>{field.label}</span>
<PalabrasSpacing
value={effective(field) as WordsSpacing | undefined}
{disabled}
onValueChange={(s) => commit(field, s)}
/>
</div>
{:else if field.type === 'typography'}
<PalabrasTypography
value={typoValue()}
fonts={PALABRAS_FONTS}
defaultFontSize={blockDefaultPx()}
{disabled}
onPatch={commitVisualPatch}
/>
{:else if field.type === 'color'}
<PalabrasColorRow
label={field.label}
@ -438,15 +575,35 @@
</Tabs.Content>
<Tabs.Content value="diseño">
<div data-palabras-panel-fields>
{#if disenoFields.length}
{#each disenoFields as field (field.key)}
{@render fieldControl(field)}
{#if disenoSections.length}
<Accordion
type="single"
collapsible
bind:value={designOpen}
variant="ghost"
size="sm"
data-palabras-design
>
{#each disenoSections as section (section.title)}
<Accordion.Item value={section.title}>
<Accordion.Header level={3}>
<Accordion.Trigger>{section.title}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-palabras-panel-fields>
{#each section.fields as field (field.key)}
{@render fieldControl(field)}
{/each}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
{:else}
</Accordion>
{:else}
<div data-palabras-panel-fields>
<p data-palabras-tab-empty>Pendiente</p>
{/if}
</div>
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="avanzado">
@ -462,6 +619,62 @@
</Tabs.Content>
</Tabs>
</FloatPanel.Body>
<!-- Footer — per-block actions in ONE place (the gutter no longer opens a
menu): Mover arriba · Insertar bloque · Mover abajo. The insert opens
the SAME block-type grid the gutter's submenu used
(`data-palabras-insert-grid` + `insertableBlockTypes`). Delete lives in
the header. -->
<div data-palabras-panel-foot>
<Button
variant="ghost"
size="xs"
iconOnly
disabled={!canMoveUp}
aria-label="Mover arriba"
title="Mover arriba"
onclick={doMoveUp}
>
{#snippet icon()}<ArrowBigUp />{/snippet}
</Button>
<DropdownMenu>
<DropdownMenu.Trigger variant="ghost" size="xs" data-words-external-tool>
{#snippet icon()}<LayersPlus />{/snippet}
Insertar bloque
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="top"
align="center"
preventScroll={false}
data-palabras-handle-menu
data-palabras-insert-grid
data-words-external-tool
>
{#each insertTypes as bt (bt.id)}
{@const Icon = bt.icon}
<DropdownMenu.Item onSelect={() => doInsertType(bt)}>
<Icon />
<span>{bt.label}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<Button
variant="ghost"
size="xs"
iconOnly
disabled={!canMoveDown}
aria-label="Mover abajo"
title="Mover abajo"
onclick={doMoveDown}
>
{#snippet icon()}<ArrowBigDown />{/snippet}
</Button>
</div>
{:else}
<FloatPanel.Body>
<p data-palabras-panel-empty>Selecciona un bloque</p>

@ -0,0 +1,168 @@
<script lang="ts">
/**
* Margin / box-spacing control for the palabras panel — two axes (vertical
* `block` = arriba/abajo, horizontal `inline` = izquierda/derecha), each a pair
* of px `<CssField>`s plus a LINK toggle (Figma / Photoshop convention).
*
* When an axis is LINKED, editing either side writes the axis key
* (`block` / `inline`) so both sides share the value; when UNLINKED, each side
* writes its own per-side key (`blockStart` / `blockEnd`,
* `inlineStart` / `inlineEnd`). The engine resolves `blockStart ?? block`, etc.
*
* Link state is DATA-DERIVED — an axis is linked while neither of its per-side
* keys is set. Toggling the link writes data to flip it, so switching blocks
* resets the link state automatically (no stale local state).
*
* Palabras-scoped: bundles panel defaults; NOT a framework primitive — it just
* composes the framework `<CssField>` + `<Button>`. `WordsSpacing` values are
* raw px numbers (the engine's spacing model is numeric pixels).
*/
import { CssField } from '$uix/eidos/components/css-field';
import { Button } from '$uix/eidos/components/button';
import { Link2, Link2Off } from '$uix/eidos/components/icon';
import type { WordsSpacing } from '$soma/components/words';
let {
value,
disabled = false,
onValueChange
}: {
/** Current spacing (raw px numbers, per axis and/or per side). */
value: WordsSpacing | undefined;
disabled?: boolean;
/** Fired with the FULL next spacing, or `undefined` when every side is cleared. */
onValueChange: (value: WordsSpacing | undefined) => void;
} = $props();
// An axis is "linked" while neither per-side key is set (only the axis key, or
// nothing). Purely data-derived so block switches reset it for free.
const blockLinked = $derived(value?.blockStart == null && value?.blockEnd == null);
const inlineLinked = $derived(value?.inlineStart == null && value?.inlineEnd == null);
// Resolved per-side values shown in each field: per-side ?? axis.
const top = $derived(value?.blockStart ?? value?.block);
const bottom = $derived(value?.blockEnd ?? value?.block);
const left = $derived(value?.inlineStart ?? value?.inline);
const right = $derived(value?.inlineEnd ?? value?.inline);
const fmt = (n: number | undefined): string | undefined => (n == null ? undefined : `${n}px`);
function parse(v: string | undefined): number | undefined {
if (!v) return undefined;
const n = parseFloat(v);
return Number.isFinite(n) ? n : undefined;
}
/** Merge a patch into the current spacing, drop cleared (non-number) keys, and
* emit `undefined` when nothing remains (so the prop clears). */
function emit(patch: Partial<Record<keyof WordsSpacing, number | undefined>>) {
const merged: Record<string, number> = {};
for (const [k, v] of Object.entries({ ...value, ...patch })) {
if (typeof v === 'number') merged[k] = v;
}
onValueChange(Object.keys(merged).length ? (merged as WordsSpacing) : undefined);
}
// Vertical (block) axis.
function setTop(v: string | undefined) {
const n = parse(v);
emit(blockLinked ? { block: n, blockStart: undefined, blockEnd: undefined } : { blockStart: n });
}
function setBottom(v: string | undefined) {
const n = parse(v);
emit(blockLinked ? { block: n, blockStart: undefined, blockEnd: undefined } : { blockEnd: n });
}
function toggleBlockLink() {
if (blockLinked) emit({ blockStart: top, blockEnd: bottom, block: undefined });
else emit({ block: top, blockStart: undefined, blockEnd: undefined });
}
// Horizontal (inline) axis.
function setLeft(v: string | undefined) {
const n = parse(v);
emit(
inlineLinked
? { inline: n, inlineStart: undefined, inlineEnd: undefined }
: { inlineStart: n }
);
}
function setRight(v: string | undefined) {
const n = parse(v);
emit(
inlineLinked ? { inline: n, inlineStart: undefined, inlineEnd: undefined } : { inlineEnd: n }
);
}
function toggleInlineLink() {
if (inlineLinked) emit({ inlineStart: left, inlineEnd: right, inline: undefined });
else emit({ inline: left, inlineStart: undefined, inlineEnd: undefined });
}
</script>
{#snippet fieldCss(val: number | undefined, onChange: (v: string | undefined) => void)}
<CssField
size="xs"
value={fmt(val)}
allowedUnits={['px']}
allowNegative={false}
{disabled}
onValueChange={onChange}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="0" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
{/snippet}
{#snippet linkBtn(linked: boolean, axis: string, onToggle: () => void)}
<Button
variant="ghost"
size="xs"
iconOnly
{disabled}
data-palabras-spacing-link
aria-pressed={linked}
aria-label={linked ? `Desvincular ${axis}` : `Vincular ${axis}`}
title={linked ? 'Vinculado — pulsa para separar' : 'Separado — pulsa para vincular'}
onclick={onToggle}
>
{#snippet icon()}{#if linked}<Link2 />{:else}<Link2Off />{/if}{/snippet}
</Button>
{/snippet}
<!-- Each axis is a 2-row grid: labels on the top row, the two inputs + the link on
the bottom row. The link lives in the INPUT row (col 3); `align-items: center`
centres it on the inputs, independent of the label height above. -->
{#snippet axis(
la: string,
va: number | undefined,
oa: (v: string | undefined) => void,
lb: string,
vb: number | undefined,
ob: (v: string | undefined) => void,
linked: boolean,
axisName: string,
onToggle: () => void
)}
<div data-palabras-spacing-axis>
<span data-palabras-spacing-side>{la}</span>
<span data-palabras-spacing-side>{lb}</span>
<span data-palabras-spacing-side aria-hidden="true"></span>
{@render fieldCss(va, oa)}
{@render fieldCss(vb, ob)}
{@render linkBtn(linked, axisName, onToggle)}
</div>
{/snippet}
<div data-palabras-spacing data-disabled={disabled ? '' : undefined}>
{@render axis('Arriba', top, setTop, 'Abajo', bottom, setBottom, blockLinked, 'arriba y abajo', toggleBlockLink)}
{@render axis(
'Izquierda',
left,
setLeft,
'Derecha',
right,
setRight,
inlineLinked,
'izquierda y derecha',
toggleInlineLink
)}
</div>

@ -0,0 +1,428 @@
<script lang="ts">
/**
* Typography control bundle for the palabras panel — one self-contained
* mega-control that drives a TEXT block's typographic visual overrides
* (`setBlockVisualAtPath` keys: fontFamily / fontStyle / strikethrough /
* fontSize / fontWeight / lineHeight / letterSpacing / wordSpacing / textWrap /
* maxWidth / tabularNumbers).
*
* EXCLUSION MODEL — one CSS property = one owner. Where the theme ships a preset
* SCALE (Leading → line-height, Tracking → letter-spacing, Measure → max-width)
* the preset chips and the manual input drive the SAME prop: picking a chip
* writes the scale value, editing the manual writes a raw value, and the active
* chip is simply "the stored value matches a scale step". There is never a
* separate "preset" and "manual" value to reconcile — there's only the one prop.
*
* Palabras-scoped (NOT a framework primitive): it composes the framework Select /
* Slider / CssField / Checkbox / ToggleGroup, like PalabrasSpacing does.
*/
import { Select } from '$uix/eidos/components/select';
import { Slider } from '$uix/eidos/components/slider';
import { CssField } from '$uix/eidos/components/css-field';
import { Checkbox } from '$uix/eidos/components/checkbox';
import { Button } from '$uix/eidos/components/button';
import { Strikethrough } from '$uix/eidos/components/icon';
import PalabrasSegmented from './palabras-segmented.svelte';
import type { PalabrasFontGroup, PalabrasFieldOption } from './types';
/** The typographic subset of a block's visual overrides. */
type Typo = {
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;
};
let {
value,
fonts,
defaultFontSize,
disabled = false,
onPatch
}: {
/** Current typographic visual values of the active block. */
value: Typo;
/** Grouped font options for the Familia dropdown (theme + schema fonts). */
fonts: readonly PalabrasFontGroup[];
/** The block's default font-size (px) — Tamaño rests here while `fontSize` is
* not overridden (i.e. the selected heading level's own size). */
defaultFontSize?: number;
disabled?: boolean;
/** Merge-patch into the block's visual; an `undefined` value clears the prop. */
onPatch: (patch: Partial<Typo>) => void;
} = $props();
// ── Theme scales — mirror generated/base.css (--leading-* / --tracking-* / --measure-*). ──
// Leading → line-height. Labels are the THEME's canonical leading steps
// (--leading-none … --leading-loose); the value is the step's multiplier.
const LEADING_DEFAULT = '1.5'; // --leading-normal (the theme's resting leading).
const LEADING: PalabrasFieldOption[] = [
{ value: '1', label: 'Sólido' },
{ value: '1.15', label: 'Ceñido' },
{ value: '1.3', label: 'Justo' },
{ value: '1.5', label: 'Normal' },
{ value: '1.65', label: 'Cómodo' },
{ value: '2', label: 'Suelto' }
];
// Tracking → letter-spacing. Labels are the THEME's canonical tracking steps
// (--tracking-tighter … --tracking-wider); the value is the step's em.
const TRACKING_DEFAULT = '0'; // --tracking-normal.
const TRACKING: PalabrasFieldOption[] = [
{ value: '-0.04em', label: 'Estrecho' },
{ value: '-0.02em', label: 'Ceñido' },
{ value: '0', label: 'Normal' },
{ value: '0.02em', label: 'Amplio' },
{ value: '0.05em', label: 'Ancho' }
];
// Measure → max-width. Labels are the THEME's canonical measure steps
// (--measure-narrow / normal / wide); the value is the step's ch.
const MEASURE: PalabrasFieldOption[] = [
{ value: '54ch', label: 'Estrecho' },
{ value: '66ch', label: 'Normal' },
{ value: '78ch', label: 'Ancho' }
];
const STYLE_OPTS: PalabrasFieldOption[] = [
{ value: 'normal', label: 'Normal' },
{ value: 'italic', label: 'Cursiva' },
{ value: 'oblique', label: 'Oblicua' }
];
const WRAP_OPTS: PalabrasFieldOption[] = [
{ value: 'wrap', label: 'Normal' },
{ value: 'nowrap', label: 'Sin corte' },
{ value: 'balance', label: 'Equilibrar' },
{ value: 'pretty', label: 'Pulido' }
];
// Canonical weight ladder; the leading `Por defecto` item clears the override.
const WEIGHTS = [
{ value: 100, label: 'Fino' },
{ value: 200, label: 'Extrafino' },
{ value: 300, label: 'Ligero' },
{ value: 400, label: 'Normal' },
{ value: 500, label: 'Medio' },
{ value: 600, label: 'Semibold' },
{ value: 700, label: 'Negrita' },
{ value: 800, label: 'Extranegrita' },
{ value: 900, label: 'Negro' }
];
const WEIGHT_DEFAULT = '__default__';
// 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);
// Tamaño shown = the override, else the block's default (the H level's own size).
const sizeEffective = $derived(value.fontSize ?? defaultFontSize);
const leadingSlider = $derived(value.lineHeight ?? Number(LEADING_DEFAULT));
const fontValue = $derived(value.fontFamily ? [value.fontFamily] : []);
const weightValue = $derived(value.fontWeight != null ? [String(value.fontWeight)] : []);
const wrapValue = $derived(value.textWrap ? [value.textWrap] : []);
// ── Patch helpers (one prop each; `undefined`/falsy clears to the block default). ──
const setStr = (key: 'letterSpacing' | 'wordSpacing' | 'maxWidth', s: string | undefined) =>
onPatch({ [key]: s || undefined } as Partial<Typo>);
function pickFamily(v: string | undefined) {
onPatch({ fontFamily: v || undefined });
}
function pickWeight(v: string | undefined) {
onPatch({ fontWeight: v && v !== WEIGHT_DEFAULT ? Number(v) : undefined });
}
function pickStyle(v: string | undefined) {
onPatch({ fontStyle: (v as Typo['fontStyle']) || undefined });
}
function pickWrap(v: string | undefined) {
onPatch({ textWrap: (v as Typo['textWrap']) || undefined });
}
function pickLeading(v: string | undefined) {
onPatch({ lineHeight: v ? Number(v) : undefined });
}
function setSize(n: number | undefined) {
onPatch({ fontSize: n && n > 0 ? n : undefined });
}
function setSizeStr(s: string | undefined) {
const n = s ? parseFloat(s) : NaN;
setSize(Number.isFinite(n) ? n : undefined);
}
function setLeading(n: number | undefined) {
onPatch({ lineHeight: n && n > 0 ? n : undefined });
}
</script>
<div data-palabras-typography data-disabled={disabled ? '' : undefined}>
<!-- Familia — grouped dropdown (theme fonts + schema fonts); each option previews
its own typeface. -->
<div data-palabras-field>
<span data-palabras-field-label>Familia</span>
<Select
type="single"
size="xs"
variant="surface"
value={fontValue}
{disabled}
onValueChange={(v) => pickFamily(v[0])}
>
<Select.Trigger>
<Select.Value placeholder="Por defecto" />
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content data-palabras-type-select data-words-external-tool>
<Select.Viewport>
{#each fonts as group, gi (group.label)}
<Select.Group>
<Select.GroupHeading>{group.label}</Select.GroupHeading>
{#each group.fonts as f (f.value)}
<Select.Item value={f.value} label={f.label}>
<span style:font-family={f.value}>{f.label}</span>
<Select.ItemIndicator />
</Select.Item>
{/each}
</Select.Group>
{#if gi < fonts.length - 1}<Select.Separator />{/if}
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
</div>
<!-- Estilo — font-style (single-select) + an independent Tachada (strike) toggle. -->
<div data-palabras-field>
<span data-palabras-field-label>Estilo</span>
<div data-palabras-type-style>
<PalabrasSegmented
value={value.fontStyle}
options={STYLE_OPTS}
{disabled}
ariaLabel="Estilo de fuente"
onValueChange={pickStyle}
/>
<Button
variant={value.strikethrough ? 'solid' : 'ghost'}
size="xs"
iconOnly
{disabled}
aria-pressed={!!value.strikethrough}
aria-label="Tachada"
title="Tachada"
onclick={() => onPatch({ strikethrough: value.strikethrough ? undefined : true })}
>
{#snippet icon()}<Strikethrough />{/snippet}
</Button>
</div>
</div>
<!-- Tamaño — slider (0 = default) paired with a compact px field. -->
<div data-palabras-field data-palabras-type-pair>
<span data-palabras-field-label>Tamaño</span>
<div data-palabras-type-pair-row>
<Slider
size="xs"
value={[sizeEffective ?? 0]}
min={0}
max={200}
step={1}
{disabled}
onValueChange={(v) => setSize(v[0])}
aria-label="Tamaño"
>
<Slider.Range />
<Slider.Thumb />
</Slider>
<CssField
size="xs"
steppers="stacked"
value={sizeEffective != null ? `${sizeEffective}px` : undefined}
allowedUnits={['px']}
allowNegative={false}
min={0}
max={200}
{disabled}
onValueChange={setSizeStr}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="auto" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
</div>
<!-- Peso — dropdown: a leading "Por defecto" that clears, then the weight ladder. -->
<div data-palabras-field>
<span data-palabras-field-label>Peso</span>
<Select
type="single"
size="xs"
variant="surface"
value={weightValue}
{disabled}
onValueChange={(v) => pickWeight(v[0])}
>
<Select.Trigger>
<Select.Value placeholder="Por defecto" />
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content data-palabras-type-select data-words-external-tool>
<Select.Viewport>
<Select.Item value={WEIGHT_DEFAULT} label="Por defecto">
<span>Por defecto</span>
<Select.ItemIndicator />
</Select.Item>
{#each WEIGHTS as w (w.value)}
<Select.Item value={String(w.value)} label={w.label}>
<span style:font-weight={w.value}>{w.label}</span>
<Select.ItemIndicator />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
</div>
<!-- Interlineado (Leading) — scale chips + manual slider (1–2). Same prop. -->
<div data-palabras-field data-palabras-type-scale>
<span data-palabras-field-label>Interlineado</span>
<PalabrasSegmented
value={leadingActive}
options={LEADING}
{disabled}
ariaLabel="Interlineado"
onValueChange={pickLeading}
/>
<Slider
size="xs"
value={[leadingSlider]}
min={1}
max={2}
step={0.05}
{disabled}
onValueChange={(v) => setLeading(v[0])}
aria-label="Interlineado manual"
>
<Slider.Range />
<Slider.Thumb />
</Slider>
</div>
<!-- Tracking — scale chips + manual px/em field. Same prop (letter-spacing). -->
<div data-palabras-field data-palabras-type-scale>
<span data-palabras-field-label>Tracking</span>
<PalabrasSegmented
value={trackingActive}
options={TRACKING}
{disabled}
ariaLabel="Tracking"
onValueChange={(v) => setStr('letterSpacing', v)}
/>
<CssField
size="xs"
steppers="stacked"
value={value.letterSpacing}
allowedUnits={['px', 'em']}
{disabled}
onValueChange={(v) => setStr('letterSpacing', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="0" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
<!-- Espaciado — word-spacing (no theme preset; manual only). -->
<div data-palabras-field data-inline>
<span data-palabras-field-label>Espaciado</span>
<CssField
size="xs"
steppers="stacked"
value={value.wordSpacing}
allowedUnits={['px', 'em']}
{disabled}
onValueChange={(v) => setStr('wordSpacing', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="0" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
<!-- Medida (measure) — scale chips + manual ch/rem field. Same prop (max-width). -->
<div data-palabras-field data-palabras-type-scale>
<span data-palabras-field-label>Medida</span>
<PalabrasSegmented
value={value.maxWidth}
options={MEASURE}
{disabled}
ariaLabel="Ancho de línea"
onValueChange={(v) => setStr('maxWidth', v)}
/>
<CssField
size="xs"
steppers="stacked"
value={value.maxWidth}
allowedUnits={['ch', 'rem', 'px']}
allowNegative={false}
{disabled}
onValueChange={(v) => setStr('maxWidth', v)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input placeholder="auto" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
</div>
<!-- Ajuste (Wrap) — text-wrap mode. A dropdown: the mode labels are too long for
an attached pill row. -->
<div data-palabras-field>
<span data-palabras-field-label>Ajuste</span>
<Select
type="single"
size="xs"
variant="surface"
value={wrapValue}
{disabled}
onValueChange={(v) => pickWrap(v[0])}
>
<Select.Trigger>
<Select.Value placeholder="Normal" />
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content data-palabras-type-select data-words-external-tool>
<Select.Viewport>
{#each WRAP_OPTS as o (o.value)}
<Select.Item value={String(o.value)} label={o.label}>
<span>{o.label}</span>
<Select.ItemIndicator />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
</div>
<!-- Cifras tabulares — fixed-width figures. -->
<div data-palabras-field data-inline>
<span data-palabras-field-label>Cifras tabulares</span>
<Checkbox
size="xs"
checked={!!value.tabularNumbers}
{disabled}
onCheckedChange={(c) => onPatch({ tabularNumbers: c || undefined })}
>
<Checkbox.Indicator />
</Checkbox>
</div>
</div>

@ -111,6 +111,11 @@
/* "Insertar bloque" submenu — a compact 2-column grid of type tiles. Keyboard
arrow-nav stays vertical over DOM order (mouse-first for now). */
[data-palabras-insert-grid] {
/* The footer's insert menu portals over the FloatPanel (z-index 1000 inline).
The DropdownMenu recipe reads this computed z-index and mirrors it onto the
floating wrapper, so lift it above the panel — otherwise the panel paints
over the menu and it looks like nothing opened. */
--dropdown-menu-content-z: 1100;
display: grid;
grid-template-columns: repeat(2, minmax(7.5rem, 1fr));
gap: 2px;
@ -425,6 +430,14 @@
font-family: var(--font-ui);
}
/* A touch more breathing room than the FloatPanel default (body padding =
`--space-3` = 12px). Panel-LOCAL token override — the theme and every other
FloatPanel stay at 12px. (`--space-4` is 16px, not 14, so this is a raw value.) */
[data-palabras-panel] {
--float-panel-body-px: 14px;
--float-panel-body-py: 14px;
}
/* Height: fit the content between a MIN and a MAX, then the body scrolls.
FloatPanel writes an inline FIXED height on the content element; override it to
`auto` (+ a floor + a cap) so the panel is as tall as its content. */
@ -453,6 +466,123 @@
color: var(--color-content-muted);
}
/* ── Diseño tab — accordion of style sections (Disposición, …) ─────────────── */
[data-palabras-design] {
inline-size: 100%;
margin-block-start: var(--space-1);
}
/* Section content goes EDGE-TO-EDGE — drop the recipe's horizontal padding (keep
the bottom gap). Higher specificity than the size recipe so it wins. */
[data-palabras-panel] [data-palabras-design] [data-accordion-content] {
padding: 0 0 var(--_accordion-content-py);
}
/* Headers flush LEFT only (the recipe's per-size `[data-accordion-trigger][data-size]`
rule re-adds padding at equal specificity, so we need the extra `[data-palabras-panel]`
to win). Everything else — font size, weight, colour — is the recipe's default. */
[data-palabras-panel] [data-palabras-design] [data-accordion-trigger] {
padding-inline-start: 0;
}
/* Kill the recipe's trigger hover background — with the flush-left padding it reads
as a band glued to the panel edges. The headers are quiet dividers: no hover
surface (same colour as the panel). */
[data-palabras-panel] [data-palabras-design] [data-accordion-trigger]:hover {
background: transparent;
}
[data-palabras-design] [data-palabras-panel-fields] {
margin-block: var(--space-1) var(--space-3);
}
/* Bigger separation between the section's fields (Alineación · Márgenes · Relleno):
every field EXCEPT the first gets extra top margin (the `+` combinator skips the
first one). */
[data-palabras-design] [data-palabras-field] + [data-palabras-field] {
margin-block-start: var(--space-5);
}
/* ── Márgenes / Relleno (PalabrasSpacing): two axes, each a 2-row grid ──────── */
[data-palabras-spacing] {
display: flex;
flex-direction: column;
gap: var(--space-3);
inline-size: 100%;
}
/* Per-axis 2-row grid: labels (Arriba/Abajo) on top, the two inputs + the link
below. `align-items: center` keeps the link ON the input row and centred on it,
independent of the label height above it. */
[data-palabras-spacing-axis] {
display: grid;
grid-template-columns: 1fr 1fr auto;
column-gap: var(--space-1-5);
row-gap: 2px;
align-items: center;
}
[data-palabras-spacing-side] {
font-size: var(--font-size-xxs);
color: var(--color-content-muted);
}
/* ── Tipografía (PalabrasTypography): a stacked bundle of type controls ──────── */
[data-palabras-typography] {
display: flex;
flex-direction: column;
gap: var(--space-3);
inline-size: 100%;
}
/* The bundle owns its spacing (the gap above) — neutralise the Diseño section's
inter-field margin so the controls stay compact. */
[data-palabras-typography] [data-palabras-field] + [data-palabras-field] {
margin-block-start: 0;
}
/* Familia / Peso triggers span the panel. */
[data-palabras-typography] [data-select-trigger] {
inline-size: 100%;
}
/* The Familia / Peso menu portals OVER the FloatPanel (z-index 1000 inline). The
Select's z-index lives on the floating WRAPPER (`[data-floating-wrapper]:has(>
[data-select-content])` reads `--select-content-z`), so the var must be set on the
wrapper — setting it on the content (a child) wouldn't propagate UP. `:has()`
targets the wrapper that holds OUR marked content. */
[data-floating-wrapper]:has(> [data-palabras-type-select]) {
--select-content-z: 1100;
}
/* Estilo — the font-style pills + the independent strike toggle, one row. */
[data-palabras-type-style] {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Tamaño — the slider grows; the px field is a fixed compact box beside it. */
[data-palabras-type-pair-row] {
display: flex;
align-items: center;
gap: var(--space-2);
}
[data-palabras-type-pair-row] > :first-child {
flex: 1 1 auto;
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. */
[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;
}
/* 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;
}
/* Toggle pills (Nivel): the ToggleGroup recipe renders `xs` at 10px, but the
theme's `--font-size-xs` is 12px (10px is `xxs`). Force the correct 12px. */
[data-palabras-panel][data-palabras-panel] [data-toggle-group-item] {
@ -478,7 +608,7 @@
}
[data-palabras-panel] [data-textarea-input],
[data-palabras-panel] [data-field] input {
font-size: var(--font-size-sm);
font-size: var(--font-size-xs);
}
/* Identical focus ring on both — the recipes ship different ring formulas, so unify
it here. Each rule uses its component's OWN focus selector, which already outranks
@ -713,12 +843,12 @@
pointer-events: none;
}
/* Content typography — one scale up (xs → sm) for the BODY text: field labels,
values, inputs and section titles. The header (Title) and footer keep their
chrome sizes. Scoped under both panels so it beats the base field rules. */
/* Content typography — one scale up (xs → sm) for the BODY text: values, inputs
and section titles. Field LABELS stay `xs` (the base rule) — they're captions,
not body. The header (Title) and footer keep their chrome sizes. Scoped under
both panels so it beats the base field rules. */
:is([data-palabras-panel], [data-palabras-appearance])
:is(
[data-palabras-field-label],
[data-palabras-field-todo],
[data-palabras-text],
[data-palabras-number-input],

@ -13,7 +13,69 @@
* Avanzado, and the other block types, land incrementally.
*/
import type { PalabrasBlockSchema, PalabrasPanelRegistry } from './types';
import {
TextAlignStart,
TextAlignCenter,
TextAlignEnd,
TextAlignJustify
} from '$uix/eidos/components/icon';
import type { PalabrasBlockSchema, PalabrasFontGroup, PalabrasPanelRegistry } from './types';
/** Fonts the Familia dropdown offers: the theme families (always present) plus a
* second group of schema-declared fonts. Apps extend the `Esquema` group with
* their own @font-face families. Each option is previewed IN its own typeface. */
export const PALABRAS_FONTS: readonly PalabrasFontGroup[] = [
{
label: 'Tema',
fonts: [
{ value: 'var(--font-family-primary)', label: 'Primary' },
{ value: 'var(--font-family-secondary)', label: 'Secondary' },
{ value: 'var(--font-family-display)', label: 'Display' },
{ value: 'var(--font-family-mono)', label: 'Mono' }
]
},
{
label: 'Esquema',
fonts: [
{ value: 'Georgia, serif', label: 'Georgia' },
{ value: "'Times New Roman', serif", label: 'Times' },
{ value: 'Verdana, sans-serif', label: 'Verdana' },
{ value: "'Courier New', monospace", label: 'Courier' }
]
}
];
/** Tipografía — the type section shared by every TEXT block's Diseño tab. A single
* mega-control (familia · estilo · tamaño · peso · interlineado · tracking ·
* espaciado · measure · ajuste · tabular) materialising the block's typographic
* visual overrides. Each property OWNS its theme-preset + manual override (no
* duplicate fields), so presets and manual values never fight. */
const tipografia = {
title: 'Tipografía',
fields: [{ key: 'typography', type: 'typography', label: 'Tipografía' }]
} as const;
/** Disposición — the layout section shared by every block's Diseño tab: text
* alignment + outer margins. Both are visual style overrides (`block.visual.*`). */
const disposicion = {
title: 'Disposición',
fields: [
{
key: 'align',
type: 'pills',
label: 'Alineación',
visual: true,
options: [
{ value: 'left', label: 'Izquierda', icon: TextAlignStart },
{ value: 'center', label: 'Centro', icon: TextAlignCenter },
{ value: 'right', label: 'Derecha', icon: TextAlignEnd },
{ value: 'justify', label: 'Justificado', icon: TextAlignJustify }
]
},
{ key: 'margin', type: 'spacing', label: 'Márgenes', visual: true },
{ key: 'padding', type: 'spacing', label: 'Relleno', visual: true }
]
} as const;
const heading: PalabrasBlockSchema = {
type: 'heading',
@ -26,19 +88,25 @@ const heading: PalabrasBlockSchema = {
label: 'Nivel',
// A heading always has a level — the toggle can't be cleared (H1 default).
required: true,
// `HC` (custom) auto-activates when the size is overridden; picking an H
// level clears the override (back to that level's default size).
customOption: 'HC',
customWhen: 'fontSize',
options: [
{ value: 1, label: 'H1' },
{ value: 2, label: 'H2' },
{ value: 3, label: 'H3' },
{ value: 4, label: 'H4' },
{ value: 5, label: 'H5' },
{ value: 6, label: 'H6' }
{ value: 6, label: 'H6' },
{ value: 'HC', label: 'HC' }
]
},
// `textarea` edits the block's TEXT (its inline `children`), not a prop.
{ key: 'children', type: 'textarea', label: 'Texto', placeholder: 'Texto del encabezado' },
{ key: 'link', type: 'text', label: 'Enlace', placeholder: 'https://…', wide: true }
]
],
diseño: [disposicion, tipografia]
};
const paragraph: PalabrasBlockSchema = {
@ -47,7 +115,8 @@ const paragraph: PalabrasBlockSchema = {
dotToken: 'var(--color-content-muted)',
contenido: [
{ key: 'children', type: 'textarea', label: 'Texto', placeholder: 'Texto del párrafo' }
]
],
diseño: [disposicion, tipografia]
};
const image: PalabrasBlockSchema = {

@ -43,8 +43,28 @@ export type PalabrasFieldType =
| 'number'
| 'slider'
| 'length'
| 'spacing'
| 'typography'
| 'color';
/** One selectable font in the Familia dropdown. */
export interface PalabrasFont {
/** Raw CSS font-family written to the block (e.g. `'var(--font-family-primary)'`
* for a theme font, or `'Georgia, serif'` for a schema-declared one). */
readonly value: string;
/** Display name — rendered IN this font so the list previews each typeface. */
readonly label: string;
/** Canonical weights this font ships (font-weight options). Absent → the
* standard 100–900 ladder. */
readonly weights?: readonly number[];
}
/** A titled group of fonts in the Familia dropdown (theme fonts vs schema fonts). */
export interface PalabrasFontGroup {
readonly label: string;
readonly fonts: readonly PalabrasFont[];
}
/** One choice in a `pills` / `select` field. */
export interface PalabrasFieldOption {
/** Value written raw to the block prop when picked. */
@ -109,6 +129,13 @@ export interface PalabrasFieldDef {
* For props that must always carry a value, e.g. a heading's `level` (a heading
* is always some level; H1 by default). */
readonly required?: boolean;
/** `pills` field: a synthetic "custom" option (value = this) shown ACTIVE when the
* `customWhen` prop is set on the block — e.g. a heading's Nivel shows an `HC`
* pill when `fontSize` is overridden. The pill is an INDICATOR: clicking it is a
* no-op; picking a REAL option clears `customWhen` (back to that level's default). */
readonly customOption?: string | number;
/** The block prop whose presence activates `customOption`. */
readonly customWhen?: string;
/** Read-only display (e.g. an `'Auto'` chip). */
readonly readOnly?: boolean;
}
@ -138,8 +165,9 @@ export interface PalabrasBlockSchema {
readonly dotToken?: string;
/** Tab "Contenido" (default). */
readonly contenido: readonly PalabrasFieldDef[];
/** Tab "Diseño". */
readonly diseño?: readonly PalabrasFieldDef[];
/** Tab "Diseño" — visual style grouped into accordion sections (Disposición,
* Tipografía, Colores, Bordes …). Each section is one collapsible group. */
readonly diseño?: readonly PalabrasSectionDef[];
/** Tab "Avanzado". */
readonly avanzado?: readonly PalabrasFieldDef[];
}

@ -1248,17 +1248,23 @@
--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: var(--radius-md);
--number-field-control-radius: 0;
--number-field-control-border-width: var(--border-width);
--number-field-control-border: transparent;
--number-field-control-border-hover: var(--color-border-subtle);
--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-surface-raised);
--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);
@ -1305,6 +1311,10 @@
--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);

@ -1074,17 +1074,31 @@ export const THEME_BASE_RECIPE_TOKENS = {
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)',
// Steppers are square segments flush to the field edge (the field clips
// their outer corners), separated from the input by a divider on the
// inner side.
'control-radius': '0',
'control-border-width': 'var(--border-width)',
'control-border': 'transparent',
'control-border-hover': 'var(--color-border-subtle)',
'control-divider': 'var(--color-border-default)',
'control-border-hover': 'var(--color-border-strong)',
'control-border-active': 'var(--color-primary-border)',
'control-bg': 'transparent',
'control-bg-hover': 'var(--color-surface-raised)',
// Higher-contrast hover/focus fill than the field surface.
'control-bg-hover': 'var(--color-neutral-hover)',
'control-bg-focus': 'var(--color-neutral-hover)',
'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',
@ -1135,6 +1149,14 @@ export const THEME_BASE_RECIPE_TOKENS = {
'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',

@ -216,7 +216,14 @@ const VISUAL_KEYS = [
'fontSize',
'fontFamily',
'fontWeight',
'lineHeight'
'lineHeight',
'fontStyle',
'strikethrough',
'letterSpacing',
'wordSpacing',
'textWrap',
'maxWidth',
'tabularNumbers'
] as const;
function mergeVisual<T extends Block>(target: T, visual: Partial<Block>): T {

@ -44,7 +44,14 @@ const STYLE_KEYS = [
'fontSize',
'fontFamily',
'fontWeight',
'lineHeight'
'lineHeight',
'fontStyle',
'strikethrough',
'letterSpacing',
'wordSpacing',
'textWrap',
'maxWidth',
'tabularNumbers'
] as const;
export function setBlockVisual(

@ -389,6 +389,13 @@ export function blockStyle(
if (block.fontFamily !== undefined) out['font-family'] = block.fontFamily;
if (block.fontWeight !== undefined) out['font-weight'] = String(block.fontWeight);
if (block.lineHeight !== undefined) out['line-height'] = String(block.lineHeight);
if (block.fontStyle !== undefined) out['font-style'] = block.fontStyle;
if (block.strikethrough) out['text-decoration-line'] = 'line-through';
if (block.letterSpacing !== undefined) out['letter-spacing'] = block.letterSpacing;
if (block.wordSpacing !== undefined) out['word-spacing'] = block.wordSpacing;
if (block.textWrap !== undefined) out['text-wrap'] = block.textWrap;
if (block.maxWidth !== undefined) out['max-width'] = block.maxWidth;
if (block.tabularNumbers) out['font-variant-numeric'] = 'tabular-nums';
const b = block.border;
if (b) {
if (b.color !== undefined) out['border-color'] = b.color;

@ -115,6 +115,20 @@ export interface Block {
readonly fontWeight?: number;
/** Line height — a RAW unitless multiplier (e.g. `1.6`). */
readonly lineHeight?: number;
/** Font slant. `oblique` is a synthesised/true slant distinct from `italic`. */
readonly fontStyle?: 'normal' | 'italic' | 'oblique';
/** Strike-through the whole block (`text-decoration-line: line-through`). */
readonly strikethrough?: boolean;
/** Letter spacing (tracking) — a RAW CSS length (e.g. `'0.02em'`, `'1px'`). */
readonly letterSpacing?: string;
/** Word spacing — a RAW CSS length (e.g. `'2px'`). */
readonly wordSpacing?: string;
/** Line-wrap behaviour (`text-wrap`). */
readonly textWrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty';
/** Max line length (measure) — a RAW CSS length (e.g. `'66ch'`, `'40rem'`). */
readonly maxWidth?: string;
/** Tabular (fixed-width) figures — `font-variant-numeric: tabular-nums`. */
readonly tabularNumbers?: boolean;
}
// ── Inline marks ──────────────────────────────────────────────────────────────

@ -219,6 +219,48 @@ function validateStyle(node: Record<string, unknown>, path: string, errors: Vali
});
}
}
for (const key of ['letterSpacing', 'wordSpacing', 'maxWidth'] as const) {
const v = node[key];
if (v !== undefined && typeof v !== 'string') {
errors.push({
path: `${path}/${key}`,
code: 'invalid-visual-value',
message: `${key} must be a string (raw CSS length); got ${JSON.stringify(v)}`
});
}
}
for (const key of ['strikethrough', 'tabularNumbers'] as const) {
const v = node[key];
if (v !== undefined && typeof v !== 'boolean') {
errors.push({
path: `${path}/${key}`,
code: 'invalid-visual-value',
message: `${key} must be a boolean; got ${JSON.stringify(v)}`
});
}
}
if (
node.fontStyle !== undefined &&
(typeof node.fontStyle !== 'string' ||
!(['normal', 'italic', 'oblique'] as readonly string[]).includes(node.fontStyle))
) {
errors.push({
path: `${path}/fontStyle`,
code: 'invalid-enum-value',
message: `fontStyle must be normal | italic | oblique; got ${JSON.stringify(node.fontStyle)}`
});
}
if (
node.textWrap !== undefined &&
(typeof node.textWrap !== 'string' ||
!(['wrap', 'nowrap', 'balance', 'pretty'] as readonly string[]).includes(node.textWrap))
) {
errors.push({
path: `${path}/textWrap`,
code: 'invalid-enum-value',
message: `textWrap must be wrap | nowrap | balance | pretty; got ${JSON.stringify(node.textWrap)}`
});
}
if (node.shadow !== undefined) validateShadow(node.shadow, `${path}/shadow`, errors);
}

@ -3,6 +3,7 @@
CssField,
type CssFieldColor,
type CssFieldSize,
type CssFieldSteppers,
type CssFieldVariant,
type CssFieldValidate
} from '$uix/eidos/components/css-field';
@ -36,6 +37,8 @@
let size = $state<CssFieldSize>('md');
let variant = $state<CssFieldVariant>('surface');
let color = $state<CssFieldColor>('primary');
let steppers = $state<CssFieldSteppers>('split');
let stepperLayoutValue = $state<string | undefined>('16px');
let lastInvalid = $state<string | null>(null);
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
@ -158,9 +161,9 @@
'</' + 'script>',
'',
`<CssField.Provider bind:value allowedUnits={${JSON.stringify(selectedUnits)}}>`,
' <CssField.DecrementTrigger>−</CssField.DecrementTrigger>',
' <CssField.DecrementTrigger />',
' <CssField.Input />',
' <CssField.IncrementTrigger>+</CssField.IncrementTrigger>',
' <CssField.IncrementTrigger />',
' <CssField.Scrubber>drag</CssField.Scrubber>',
'</CssField.Provider>'
].join('\n')
@ -181,10 +184,11 @@
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
steppers !== 'split' && ` steppers="${steppers}"`,
'>',
' <CssField.DecrementTrigger>−</CssField.DecrementTrigger>',
' <CssField.DecrementTrigger />',
' <CssField.Input />',
' <CssField.IncrementTrigger>+</CssField.IncrementTrigger>',
' <CssField.IncrementTrigger />',
' <CssField.Scrubber>drag</CssField.Scrubber>',
'</CssField>'
]
@ -234,12 +238,13 @@
{size}
{variant}
{color}
{steppers}
onInvalid={(v) => (lastInvalid = v)}
onValueCommit={() => (lastInvalid = null)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.DecrementTrigger />
<CssField.Input aria-label="CSS value" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
<CssField.IncrementTrigger />
<CssField.Scrubber>drag</CssField.Scrubber>
</CssField>
</div>
@ -468,6 +473,72 @@
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>steppers</span>
<span data-uix-chips role="radiogroup">
{#each ['split', 'stacked'] as item}
<button
data-uix-chip
data-active={steppers === item}
onclick={() => (steppers = item as CssFieldSteppers)}>{item}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> stepper layout · <code>steppers</code>
</div>
<p data-uix-section-desc>
<code>split</code> (default) — decrement/increment at the two ends. <code>stacked</code> —
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.
</p>
<!-- Plain wrappers (NOT data-uix-control) so the demo's native-input border
doesn't leak into the CssField's own input. -->
<div
style="display:flex; flex-wrap:wrap; gap:var(--space-6); align-items:flex-start; margin-block-start:var(--space-2)"
>
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:13rem">
<span data-uix-control-label>split</span>
<CssField
bind:value={stepperLayoutValue}
allowedUnits={['px', 'rem', '%']}
aria-label="Split"
>
<CssField.DecrementTrigger />
<CssField.Input aria-label="Split stepper" />
<CssField.IncrementTrigger />
</CssField>
</div>
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:13rem">
<span data-uix-control-label>stacked</span>
<CssField
bind:value={stepperLayoutValue}
allowedUnits={['px', 'rem', '%']}
steppers="stacked"
aria-label="Stacked"
>
<CssField.DecrementTrigger />
<CssField.Input aria-label="Stacked stepper" />
<CssField.IncrementTrigger />
</CssField>
</div>
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:13rem">
<span data-uix-control-label>stacked · rtl</span>
<CssField
bind:value={stepperLayoutValue}
allowedUnits={['px', 'rem', '%']}
steppers="stacked"
dir="rtl"
aria-label="Stacked RTL"
>
<CssField.DecrementTrigger />
<CssField.Input aria-label="Stacked RTL stepper" />
<CssField.IncrementTrigger />
</CssField>
</div>
</div>
<div data-uix-code>

@ -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<NumberFieldSize>('md');
let variant = $state<NumberFieldVariant>('surface');
let color = $state<NumberFieldColor>('primary');
let steppers = $state<NumberFieldSteppers>('split');
let stepperLayoutValue = $state(8);
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
@ -249,9 +252,9 @@
'</' + 'script>',
'',
`<NumberField.Provider bind:value min={${min}} max={${max}} step={${step}}>`,
' <NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>',
' <NumberField.DecrementTrigger />',
' <NumberField.Input />',
' <NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>',
' <NumberField.IncrementTrigger />',
' <NumberField.Scrubber>drag</NumberField.Scrubber>',
'</NumberField.Provider>'
].join('\n')
@ -275,10 +278,11 @@
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
steppers !== 'split' && ` steppers="${steppers}"`,
'>',
' <NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>',
' <NumberField.DecrementTrigger />',
' <NumberField.Input />',
' <NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>',
' <NumberField.IncrementTrigger />',
' <NumberField.Scrubber>drag</NumberField.Scrubber>',
'</NumberField>'
]
@ -325,10 +329,11 @@
{size}
{variant}
{color}
{steppers}
>
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
<NumberField.DecrementTrigger />
<NumberField.Input aria-label="Value" />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
<NumberField.IncrementTrigger />
<NumberField.Scrubber>drag</NumberField.Scrubber>
</NumberField>
</div>
@ -378,6 +383,126 @@
<span data-uix-layer-badge="soma">soma</span> owns the spinbutton model.
<span data-uix-layer-badge="eidos">eidos</span> owns size.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> stepper layout · <code>steppers</code>
</div>
<p data-uix-section-desc>
<code>split</code> (default) — decrement/increment flush at the two ends.
<code>stacked</code> — one compact column on the inline-end (▲ top / ▼ bottom); flips to
the inline-start under RTL.
</p>
<!-- Plain wrappers (NOT data-uix-control) so the demo's native-input border
doesn't leak into the NumberField's own input. -->
<div
style="display:flex; flex-wrap:wrap; gap:var(--space-6); align-items:flex-start; margin-block-start:var(--space-2)"
>
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:13rem">
<span data-uix-control-label>split</span>
<NumberField bind:value={stepperLayoutValue} min={0} max={100} aria-label="Split">
<NumberField.DecrementTrigger />
<NumberField.Input aria-label="Split stepper" />
<NumberField.IncrementTrigger />
</NumberField>
</div>
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:13rem">
<span data-uix-control-label>stacked</span>
<NumberField
bind:value={stepperLayoutValue}
min={0}
max={100}
steppers="stacked"
aria-label="Stacked"
>
<NumberField.DecrementTrigger />
<NumberField.Input aria-label="Stacked stepper" />
<NumberField.IncrementTrigger />
</NumberField>
</div>
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:13rem">
<span data-uix-control-label>stacked · rtl</span>
<NumberField
bind:value={stepperLayoutValue}
min={0}
max={100}
steppers="stacked"
dir="rtl"
aria-label="Stacked RTL"
>
<NumberField.DecrementTrigger />
<NumberField.Input aria-label="Stacked RTL stepper" />
<NumberField.IncrementTrigger />
</NumberField>
</div>
</div>
<p data-uix-section-desc style="margin-block-start:var(--space-4)">
The stacked column height tracks the field <code>size</code> — each arrow fills half the
field height, so it grows from <code>xs</code> to <code>xl</code>.
</p>
<div
style="display:flex; flex-wrap:wrap; gap:var(--space-4); align-items:flex-end; margin-block-start:var(--space-2)"
>
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as fieldSize}
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:9rem">
<span data-uix-control-label>{fieldSize}</span>
<NumberField
bind:value={stepperLayoutValue}
min={0}
max={100}
size={fieldSize as NumberFieldSize}
steppers="stacked"
aria-label={`Stacked ${fieldSize}`}
>
<NumberField.DecrementTrigger />
<NumberField.Input aria-label={`Stacked ${fieldSize} stepper`} />
<NumberField.IncrementTrigger />
</NumberField>
</div>
{/each}
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> stepper glyphs · themeable
</div>
<p data-uix-section-desc>
Triggers render a default glyph from a token, so a theme can restyle every stepper at
once — override <code>--number-field-control-increment-glyph</code> /
<code>-decrement-glyph</code> (and their <code>-stacked</code> siblings). Passing children
to a trigger overrides the token for that instance.
</p>
<div
style="display:flex; flex-wrap:wrap; gap:var(--space-6); align-items:flex-start; margin-block-start:var(--space-2)"
>
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:13rem">
<span data-uix-control-label>token override</span>
<NumberField
bind:value={stepperLayoutValue}
min={0}
max={100}
aria-label="Token override"
style="--number-field-control-increment-glyph: '⊕'; --number-field-control-decrement-glyph: '⊖'"
>
<NumberField.DecrementTrigger />
<NumberField.Input aria-label="Token override stepper" />
<NumberField.IncrementTrigger />
</NumberField>
</div>
<div style="display:flex; flex-direction:column; gap:var(--space-1-5); inline-size:13rem">
<span data-uix-control-label>children override</span>
<NumberField
bind:value={stepperLayoutValue}
min={0}
max={100}
aria-label="Children override"
>
<NumberField.DecrementTrigger>less</NumberField.DecrementTrigger>
<NumberField.Input aria-label="Children override stepper" />
<NumberField.IncrementTrigger>more</NumberField.IncrementTrigger>
</NumberField>
</div>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · numeric model
</div>
@ -610,6 +735,18 @@
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>steppers</span>
<span data-uix-chips role="radiogroup">
{#each ['split', 'stacked'] as item}
<button
data-uix-chip
data-active={steppers === item}
onclick={() => (steppers = item as NumberFieldSteppers)}>{item}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-code>

Loading…
Cancel
Save

Powered by TurnKey Linux.