feat(uix): add CssField — validated CSS-value spinbutton + sium CSS helpers

New 4-layer UIX component for CSS dimension values (px/rem/%/vh/…) modeled on number-field: allowedUnits whitelist, optional increment/decrement triggers, pointer scrubber, keyboard stepping, wheel, Field integration. Validation is ambivalent — built-in pure sium functions by default, or delegate to a sium schema via the validate prop (untilFix feedback on invalid units).

sium: parseCssValue/formatCssValue/isCssValue/cssValue + CSS unit vocabulary in arts/sium/types/css.ts; css_value issue code + es/en messages; tests. morfo/soma/sema/eidos: css-field across all layers; recipe tokens + interactive demo + sidebar link + READMEs.

Verified: check 0 errors; sium 395, soma css-field 9, morfo+sema 214 tests; morfo:check + eidos-lint clean; browser render/behavior/sema-trace confirmed.

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

@ -116,7 +116,25 @@ export {
saturation,
lightness,
brightness,
alpha
alpha,
cssValue,
parseCssValue,
formatCssValue,
isCssValue,
CSS_UNITS,
CSS_LENGTH_UNITS,
CSS_ANGLE_UNITS,
CSS_TIME_UNITS,
CSS_RESOLUTION_UNITS,
CSS_FREQUENCY_UNITS,
CSS_FLEX_UNITS
} from './types';
export type {
CssUnit,
CssValueParts,
CssDimension,
CssKeyword,
CssValueOptions
} from './types';
// langs

@ -22,6 +22,7 @@ export const ISSUE_CODES = [
'email',
'url',
'integer',
'css_value',
'custom',
'range_order',
// combinators

@ -102,6 +102,10 @@ export const siumLangs = {
es: 'Debe ser un entero (se recibió {{actual}})',
en: 'Must be an integer (received {{actual}})'
},
css_value: {
es: 'Debe ser un valor CSS válido ({{units}})',
en: 'Must be a valid CSS value ({{units}})'
},
custom: {
es: 'Valor no válido',
en: 'Invalid value'

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { pipe, string } from '../core';
import {
cssValue,
parseCssValue,
formatCssValue,
isCssValue,
CSS_UNITS
} from '../types/css';
describe('parseCssValue()', () => {
it('parses a px dimension', () => {
expect(parseCssValue('12px')).toEqual({ kind: 'dimension', value: 12, unit: 'px' });
});
it('parses decimals and leading-dot', () => {
expect(parseCssValue('1.5rem')).toEqual({ kind: 'dimension', value: 1.5, unit: 'rem' });
expect(parseCssValue('.5em')).toEqual({ kind: 'dimension', value: 0.5, unit: 'em' });
});
it('parses percentage', () => {
expect(parseCssValue('50%')).toEqual({ kind: 'dimension', value: 50, unit: '%' });
});
it('trims surrounding whitespace', () => {
expect(parseCssValue(' 24px ')).toEqual({ kind: 'dimension', value: 24, unit: 'px' });
});
it('matches units case-insensitively and canonicalizes', () => {
expect(parseCssValue('12PX')).toEqual({ kind: 'dimension', value: 12, unit: 'px' });
});
it('accepts a bare zero as unitless', () => {
expect(parseCssValue('0')).toEqual({ kind: 'dimension', value: 0, unit: '' });
});
it('rejects a bare non-zero number (unit required)', () => {
expect(parseCssValue('12')).toBeNull();
});
it('rejects negatives by default, accepts with allowNegative', () => {
expect(parseCssValue('-8px')).toBeNull();
expect(parseCssValue('-8px', { allowNegative: true })).toEqual({
kind: 'dimension',
value: -8,
unit: 'px'
});
});
it('rejects a unit outside the whitelist', () => {
expect(parseCssValue('50%', { units: ['px', 'rem'] })).toBeNull();
expect(parseCssValue('12px', { units: ['px', 'rem'] })).toEqual({
kind: 'dimension',
value: 12,
unit: 'px'
});
});
it('parses opt-in keywords case-insensitively', () => {
expect(parseCssValue('auto', { keywords: ['auto'] })).toEqual({
kind: 'keyword',
keyword: 'auto'
});
expect(parseCssValue('AUTO', { keywords: ['auto'] })).toEqual({
kind: 'keyword',
keyword: 'auto'
});
});
it('rejects keywords that were not opted in', () => {
expect(parseCssValue('auto')).toBeNull();
});
it('rejects malformed input', () => {
expect(parseCssValue('abc')).toBeNull();
expect(parseCssValue('12.px')).toBeNull();
expect(parseCssValue('')).toBeNull();
expect(parseCssValue(42)).toBeNull();
});
});
describe('formatCssValue()', () => {
it('serializes a dimension', () => {
expect(formatCssValue({ kind: 'dimension', value: 13, unit: 'px' })).toBe('13px');
expect(formatCssValue({ kind: 'dimension', value: 1.5, unit: 'rem' })).toBe('1.5rem');
});
it('serializes a keyword', () => {
expect(formatCssValue({ kind: 'keyword', keyword: 'auto' })).toBe('auto');
});
it('round-trips parse → format for the standard units', () => {
for (const sample of ['12px', '1.5rem', '50%', '100vh', '2ch']) {
const parsed = parseCssValue(sample);
expect(parsed).not.toBeNull();
expect(formatCssValue(parsed!)).toBe(sample);
}
});
});
describe('isCssValue()', () => {
it('mirrors parseCssValue success', () => {
expect(isCssValue('12px')).toBe(true);
expect(isCssValue('12')).toBe(false);
expect(isCssValue('50%', { units: ['px'] })).toBe(false);
});
it('exposes the full unit vocabulary', () => {
expect(CSS_UNITS).toContain('px');
expect(CSS_UNITS).toContain('rem');
expect(CSS_UNITS).toContain('%');
expect(CSS_UNITS).toContain('vh');
expect(CSS_UNITS).toContain('fr');
});
});
describe('cssValue() refine', () => {
it('passes a valid value through', () => {
const schema = pipe(string(), cssValue({ units: ['px', 'rem'] }));
expect(schema.decodeSync('12px')).toBe('12px');
});
it('rejects a disallowed unit with the css_value code', async () => {
const schema = pipe(string(), cssValue({ units: ['px', 'rem'] }));
const result = await schema.validate('50%');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues[0].code).toBe('css_value');
expect(result.issues[0].message).toBe(
'#?sium.errors.css_value|Must be a valid CSS value ({{units}})'
);
}
});
it('honors allowNegative', async () => {
const schema = pipe(string(), cssValue({ units: ['px'], allowNegative: true }));
expect(schema.decodeSync('-8px')).toBe('-8px');
});
it('honors keywords', () => {
const schema = pipe(string(), cssValue({ units: ['px'], keywords: ['auto'] }));
expect(schema.decodeSync('auto')).toBe('auto');
});
});

@ -18,6 +18,7 @@ describe('ISSUE_CODES', () => {
'email',
'url',
'integer',
'css_value',
'custom',
'range_order',
'unknown_keys',
@ -26,8 +27,8 @@ describe('ISSUE_CODES', () => {
'discriminated_unknown_value'
] as const;
it('contains exactly the 18 codes in declaration order, no duplicates', () => {
expect(ISSUE_CODES).toHaveLength(18);
it('contains exactly the 19 codes in declaration order, no duplicates', () => {
expect(ISSUE_CODES).toHaveLength(19);
expect([...ISSUE_CODES]).toEqual(expectedCodes);
expect(new Set(ISSUE_CODES).size).toBe(ISSUE_CODES.length);
});
@ -47,6 +48,7 @@ describe('ISSUE_CODES', () => {
| 'email'
| 'url'
| 'integer'
| 'css_value'
| 'custom'
| 'range_order'
| 'unknown_keys'
@ -61,9 +63,9 @@ describe('ISSUE_CODES', () => {
describe('siumLangs schema', () => {
const errors = siumLangs.errors;
it('has exactly 18 keys — one per IssueCode', () => {
it('has exactly 19 keys — one per IssueCode', () => {
const keys = Object.keys(errors);
expect(keys).toHaveLength(18);
expect(keys).toHaveLength(19);
for (const code of ISSUE_CODES) {
expect(errors).toHaveProperty(code);
}

@ -0,0 +1,195 @@
import { refine } from '../core';
import type { Step } from '../core';
/**
* CSS value validation + parsing.
*
* Richer counterpart of the non-negative `cssLength()` refine in
* `core/refines.ts`: this module knows the full CSS unit vocabulary, parses a
* value string into its numeric + unit parts (needed to increment/decrement a
* value while preserving its unit), supports a configurable unit whitelist,
* opt-in keywords (`auto`, `inherit`, …) and optional negative values.
*
* The pure helpers (`parseCssValue`, `formatCssValue`, `isCssValue`) carry no
* sium dependency so any consumer can reuse them; `cssValue()` wraps them as a
* Standard-Schema refine.
*/
// ── Unit vocabulary ──────────────────────────────────────────────────────────
/** Absolute + relative + viewport + container length units, plus `%`. */
export const CSS_LENGTH_UNITS = [
'px',
'rem',
'em',
'%',
'ch',
'ex',
'cap',
'ic',
'lh',
'rlh',
'vw',
'vh',
'vmin',
'vmax',
'vi',
'vb',
'svw',
'svh',
'lvw',
'lvh',
'dvw',
'dvh',
'cqw',
'cqh',
'cqi',
'cqb',
'cqmin',
'cqmax',
'cm',
'mm',
'in',
'pt',
'pc',
'q'
] as const;
/** Angle units (gradients, transforms). */
export const CSS_ANGLE_UNITS = ['deg', 'grad', 'rad', 'turn'] as const;
/** Time units (transitions, animations). */
export const CSS_TIME_UNITS = ['s', 'ms'] as const;
/** Resolution units (media queries, image-set). */
export const CSS_RESOLUTION_UNITS = ['dpi', 'dpcm', 'dppx', 'x'] as const;
/** Frequency units. */
export const CSS_FREQUENCY_UNITS = ['hz', 'khz'] as const;
/** Flexible length unit (grid). */
export const CSS_FLEX_UNITS = ['fr'] as const;
/** The full canonical CSS unit set. */
export const CSS_UNITS = [
...CSS_LENGTH_UNITS,
...CSS_ANGLE_UNITS,
...CSS_TIME_UNITS,
...CSS_RESOLUTION_UNITS,
...CSS_FREQUENCY_UNITS,
...CSS_FLEX_UNITS
] as const;
export type CssUnit = (typeof CSS_UNITS)[number];
// ── Parsed shape ─────────────────────────────────────────────────────────────
/** A numeric CSS value with its unit (unit `''` only for a bare `0`). */
export interface CssDimension {
kind: 'dimension';
value: number;
unit: string;
}
/** A keyword CSS value (`auto`, `inherit`, …). */
export interface CssKeyword {
kind: 'keyword';
keyword: string;
}
export type CssValueParts = CssDimension | CssKeyword;
export interface CssValueOptions {
/** Allowed units (whitelist). Defaults to every CSS unit. */
units?: readonly string[];
/** Allowed bare keywords (e.g. `['auto', 'inherit']`). Default: none. */
keywords?: readonly string[];
/** Allow a leading minus sign (e.g. `-8px`). Default: `false`. */
allowNegative?: boolean;
}
// Leading number: optional sign, integer/decimal/leading-dot, optional exponent.
const LEADING_NUMBER_REGEX = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?/i;
/**
* Parses a CSS value string into its numeric + unit parts (or a keyword).
* Returns `null` when the input is not a valid CSS value under `opts`.
*
* Strict by design:
* - A non-zero number with no unit is rejected (`'12'` → `null`); only a bare
* `'0'` is a valid unitless length.
* - A unit outside `opts.units` is rejected. Parse against `CSS_UNITS` first to
* distinguish "well-formed but disallowed unit" from "malformed".
*
* @example
* parseCssValue('12px') // { kind:'dimension', value:12, unit:'px' }
* parseCssValue('1.5rem') // { kind:'dimension', value:1.5, unit:'rem' }
* parseCssValue('-8px', { allowNegative:true}) // { kind:'dimension', value:-8, unit:'px' }
* parseCssValue('auto', { keywords:['auto'] }) // { kind:'keyword', keyword:'auto' }
* parseCssValue('12', ) // null (bare non-zero number)
* parseCssValue('50%', { units:['px'] }) // null (unit not allowed)
*/
export function parseCssValue(input: unknown, opts: CssValueOptions = {}): CssValueParts | null {
if (typeof input !== 'string') return null;
const raw = input.trim();
if (raw === '') return null;
const allowedUnits = opts.units ?? CSS_UNITS;
const keywords = opts.keywords ?? [];
const allowNegative = opts.allowNegative ?? false;
// Keyword match (case-insensitive). Keywords never carry a number.
const lowered = raw.toLowerCase();
const keyword = keywords.find((k) => k.toLowerCase() === lowered);
if (keyword) return { kind: 'keyword', keyword };
const match = LEADING_NUMBER_REGEX.exec(raw);
if (!match) return null;
const numText = match[0];
const value = Number(numText);
if (!Number.isFinite(value)) return null;
if (!allowNegative && value < 0) return null;
const unit = raw.slice(numText.length).trim();
if (unit === '') {
// Only a bare zero is a valid unitless length.
return value === 0 ? { kind: 'dimension', value: 0, unit: '' } : null;
}
const canonicalUnit = allowedUnits.find((u) => u.toLowerCase() === unit.toLowerCase());
if (canonicalUnit === undefined) return null;
return { kind: 'dimension', value, unit: canonicalUnit };
}
/** Serializes parsed parts back to a canonical CSS string (`{value}{unit}`). */
export function formatCssValue(parts: CssValueParts): string {
if (parts.kind === 'keyword') return parts.keyword;
return `${parts.value}${parts.unit}`;
}
/** Sync predicate — true when `input` is a valid CSS value under `opts`. */
export function isCssValue(input: unknown, opts: CssValueOptions = {}): boolean {
return parseCssValue(input, opts) !== null;
}
/**
* Creates a CSS-value string refine. Compose as `pipe(string(), cssValue(opts))`.
* Supports a configurable unit whitelist, opt-in keywords and negatives.
*
* @example
* ```ts
* const schema = pipe(string(), cssValue({ units: ['px', 'rem'], allowNegative: true }));
* ```
*/
export function cssValue(opts: CssValueOptions = {}): Step<string, string> {
const units = (opts.units ?? CSS_UNITS).join(', ');
const keywords = (opts.keywords ?? []).join(', ');
return refine((value) => isCssValue(value, opts), {
code: 'css_value',
message: '#?sium.errors.css_value|Must be a valid CSS value ({{units}})',
params: { units, keywords }
});
}

@ -11,3 +11,17 @@ export {
brightness,
alpha
} from './color';
export {
cssValue,
parseCssValue,
formatCssValue,
isCssValue,
CSS_UNITS,
CSS_LENGTH_UNITS,
CSS_ANGLE_UNITS,
CSS_TIME_UNITS,
CSS_RESOLUTION_UNITS,
CSS_FREQUENCY_UNITS,
CSS_FLEX_UNITS
} from './css';
export type { CssUnit, CssValueParts, CssDimension, CssKeyword, CssValueOptions } from './css';

@ -0,0 +1,53 @@
# CssField (eidos)
Visual wrapper over Soma's `css-field` spinbutton. Adds only chrome — `size`,
`variant`, `color` — and projects them as `data-*` for the recipe. Parsing,
validation, the unit whitelist, scrubbing, keyboard and `Field` integration are
owned by Soma.
```svelte
<CssField bind:value allowedUnits={['px', 'rem', '%']} size="md" variant="surface" color="primary">
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
</CssField>
```
## Props (visual)
| Prop | Type | Default |
| --- | --- | --- |
| `size` | `ResponsiveProp<'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'>` | `'md'` |
| `variant` | `'surface' \| 'outline' \| 'ghost'` (`ControlVariant`) | `'surface'` |
| `color` | `ColorRole` (8 roles) | `'primary'` |
All Soma `Provider` props pass through (`value`, `allowedUnits`, `keywords`, `validate`, `min`/`max`, `step`, …).
## Recipe
Tokens live under the `css-field` recipe key in `src/uix/eidos/lib/recipes/base.ts`
(`--css-field-*`), re-mapped to internal `--_css-field-*` for the size/color cascade in
`css-field.css`. The control row reuses the same control-height / spacing tokens as
`number-field`. `[data-invalid]` tints the border with `--color-risk-border`;
`[data-focused]` / `[data-scrubbing]` raise the focus ring.
## Baseline
No Air baseline — new component. Recipe cloned from `number-field`'s with token renames.
## Comparativa
See `src/uix/soma/components/css-field/README.md` for the cross-library feature table.
Visually it is a single-row control matching the input family (DateField / NumberField /
ColorField): border + control buttons + optional scrubber, 5 sizes × 3 variants × 8 colors.
## Decisiones
- **Subset**: `size` `xs..xl` (text-input family), `variant` = `ControlVariant`, `color` = 8 roles.
- **Fixed control row height** — triggers + scrubber inherit the resolved input height.
- No component-specific visual state beyond the Soma data-attrs (`data-invalid`, `data-focused`, `data-scrubbing`).
## Gaps
- A units-suffix affordance (showing the unit separately) is not provided — the unit lives
inside the input text. _diferir_.

@ -0,0 +1,10 @@
<script lang="ts">
import * as CssField from '$soma/components/css-field';
import type { CssFieldDecrementTriggerProps } from './types';
let { children, ...rest }: CssFieldDecrementTriggerProps = $props();
</script>
<CssField.DecrementTrigger {...rest}>
{@render children?.()}
</CssField.DecrementTrigger>

@ -0,0 +1,10 @@
<script lang="ts">
import * as CssField from '$soma/components/css-field';
import type { CssFieldIncrementTriggerProps } from './types';
let { children, ...rest }: CssFieldIncrementTriggerProps = $props();
</script>
<CssField.IncrementTrigger {...rest}>
{@render children?.()}
</CssField.IncrementTrigger>

@ -0,0 +1,10 @@
<script lang="ts">
import * as CssField from '$soma/components/css-field';
import type { CssFieldInputProps } from './types';
let { children, ...rest }: CssFieldInputProps = $props();
</script>
<CssField.Input {...rest}>
{@render children?.()}
</CssField.Input>

@ -0,0 +1,10 @@
<script lang="ts">
import * as CssField from '$soma/components/css-field';
import type { CssFieldScrubberProps } from './types';
let { children, ...rest }: CssFieldScrubberProps = $props();
</script>
<CssField.Scrubber {...rest}>
{@render children?.()}
</CssField.Scrubber>

@ -0,0 +1,214 @@
[data-css-field] {
--_css-field-height: var(--css-field-height-md);
--_css-field-px: var(--css-field-px-md);
--_css-field-gap: var(--css-field-gap-md);
--_css-field-font-size: var(--css-field-font-size-md);
--_css-field-accent-border: var(--color-primary-border);
--_css-field-accent-track: var(--color-primary-track);
--_css-field-accent-text: var(--color-primary-text);
display: inline-flex;
align-items: center;
gap: var(--_css-field-gap);
inline-size: 100%;
min-inline-size: 0;
block-size: var(--_css-field-height);
padding-inline: var(--_css-field-px);
border: var(--css-field-border-width) solid var(--css-field-border);
border-radius: var(--css-field-radius);
background: var(--css-field-bg);
color: var(--css-field-color);
font-family: var(--css-field-font-family);
font-size: var(--_css-field-font-size);
line-height: var(--css-field-line-height);
transition:
background var(--css-field-transition-duration) var(--css-field-transition-ease),
border-color var(--css-field-transition-duration) var(--css-field-transition-ease),
box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease);
}
[data-css-field][data-color='secondary'] {
--_css-field-accent-border: var(--color-secondary-border);
--_css-field-accent-track: var(--color-secondary-track);
--_css-field-accent-text: var(--color-secondary-text);
}
[data-css-field][data-color='neutral'] {
--_css-field-accent-border: var(--color-neutral-border);
--_css-field-accent-track: var(--color-neutral-track);
--_css-field-accent-text: var(--color-neutral-text);
}
[data-css-field][data-color='affirm'] {
--_css-field-accent-border: var(--color-affirm-border);
--_css-field-accent-track: var(--color-affirm-track);
--_css-field-accent-text: var(--color-affirm-text);
}
[data-css-field][data-color='fulfill'] {
--_css-field-accent-border: var(--color-fulfill-border);
--_css-field-accent-track: var(--color-fulfill-track);
--_css-field-accent-text: var(--color-fulfill-text);
}
[data-css-field][data-color='risk'] {
--_css-field-accent-border: var(--color-risk-border);
--_css-field-accent-track: var(--color-risk-track);
--_css-field-accent-text: var(--color-risk-text);
}
[data-css-field][data-color='threat'] {
--_css-field-accent-border: var(--color-threat-border);
--_css-field-accent-track: var(--color-threat-track);
--_css-field-accent-text: var(--color-threat-text);
}
[data-css-field][data-color='loss'] {
--_css-field-accent-border: var(--color-loss-border);
--_css-field-accent-track: var(--color-loss-track);
--_css-field-accent-text: var(--color-loss-text);
}
[data-css-field][data-size='xs'] {
--_css-field-height: var(--css-field-height-xs);
--_css-field-px: var(--css-field-px-xs);
--_css-field-gap: var(--css-field-gap-xs);
--_css-field-font-size: var(--css-field-font-size-xs);
}
[data-css-field][data-size='sm'] {
--_css-field-height: var(--css-field-height-sm);
--_css-field-px: var(--css-field-px-sm);
--_css-field-gap: var(--css-field-gap-sm);
--_css-field-font-size: var(--css-field-font-size-sm);
}
[data-css-field][data-size='lg'] {
--_css-field-height: var(--css-field-height-lg);
--_css-field-px: var(--css-field-px-lg);
--_css-field-gap: var(--css-field-gap-lg);
--_css-field-font-size: var(--css-field-font-size-lg);
}
[data-css-field][data-size='xl'] {
--_css-field-height: var(--css-field-height-xl);
--_css-field-px: var(--css-field-px-xl);
--_css-field-gap: var(--css-field-gap-xl);
--_css-field-font-size: var(--css-field-font-size-xl);
}
[data-css-field][data-variant='outline'] {
background: transparent;
}
[data-css-field][data-variant='ghost'] {
border-color: transparent;
background: transparent;
padding-inline: 0;
}
[data-css-field][data-focused],
[data-css-field][data-scrubbing] {
border-color: var(--css-field-border-focus);
box-shadow: var(--css-field-focus-shadow);
}
[data-css-field][data-invalid] {
border-color: var(--css-field-border-invalid);
}
[data-css-field][data-disabled] {
opacity: var(--css-field-disabled-opacity);
}
[data-css-field-input] {
flex: 1 1 auto;
min-inline-size: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
outline: none;
transition: box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease);
}
[data-css-field-input][data-disabled] {
cursor: not-allowed;
}
/* Explicit keyboard focus ring on the input itself. Ensures clear focus
feedback even before the Provider's [data-focused] attribute is applied. */
[data-css-field-input]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--_css-field-accent-border);
}
[data-css-field-increment-trigger],
[data-css-field-decrement-trigger],
[data-css-field-scrubber] {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
/* Stepper buttons and scrubber inherit the Provider's resolved height so
they always match the input row, regardless of size token. */
inline-size: var(--_css-field-height);
block-size: var(--_css-field-height);
min-inline-size: var(--_css-field-height);
border: var(--css-field-control-border-width) solid var(--css-field-control-border);
border-radius: var(--css-field-control-radius);
background: var(--css-field-control-bg);
color: var(--css-field-control-color);
font: inherit;
outline: none;
transition:
background var(--css-field-transition-duration) var(--css-field-transition-ease),
border-color var(--css-field-transition-duration) var(--css-field-transition-ease),
color var(--css-field-transition-duration) var(--css-field-transition-ease),
box-shadow var(--css-field-transition-duration) var(--css-field-transition-ease);
}
[data-css-field-increment-trigger],
[data-css-field-decrement-trigger] {
cursor: pointer;
}
[data-css-field-scrubber] {
cursor: ew-resize;
}
[data-css-field-increment-trigger]:hover:not([data-disabled]),
[data-css-field-decrement-trigger]:hover:not([data-disabled]),
[data-css-field-scrubber]:hover:not([data-disabled]) {
border-color: var(--css-field-control-border-hover);
background: var(--css-field-control-bg-hover);
color: var(--css-field-control-color-hover);
}
[data-css-field-increment-trigger]:focus-visible,
[data-css-field-decrement-trigger]:focus-visible,
[data-css-field-scrubber]:focus-visible {
box-shadow: var(--css-field-control-focus-shadow);
}
[data-css-field-increment-trigger][data-disabled],
[data-css-field-decrement-trigger][data-disabled],
[data-css-field-scrubber][data-disabled] {
cursor: default;
opacity: var(--css-field-control-disabled-opacity);
}
[data-css-field-scrubber][data-scrubbing] {
border-color: var(--css-field-control-border-active);
background: var(--css-field-control-bg-active);
color: var(--css-field-control-color-active);
}
@media (prefers-reduced-motion: reduce) {
[data-css-field],
[data-css-field-increment-trigger],
[data-css-field-decrement-trigger],
[data-css-field-scrubber] {
transition: none;
}
}

@ -0,0 +1,32 @@
<script lang="ts">
import './css-field.css';
import { ActiveEidos } from '$uix/eidos';
/**
* Eidos `<CssField>` — visual wrapper over Soma's CSS-value spinbutton.
* Soma owns parsing, validation, min/max, scrub and keyboard behavior.
*/
import * as CssField from '$soma/components/css-field';
import type { CssFieldProps } from './types';
let {
size = 'md',
variant = 'surface',
color = 'primary',
value = $bindable(),
children,
...rest
}: CssFieldProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<CssField.Provider
{...rest}
bind:value
data-size={resolvedSize}
data-variant={variant}
data-color={color}
>
{@render children?.()}
</CssField.Provider>

@ -0,0 +1,44 @@
// CssField — eidos compound API, disciplined option C.
//
// import { CssField } from '$uix/eidos/components/css-field';
//
// <CssField bind:value allowedUnits={['px', 'rem', '%']}>
// <CssField.DecrementTrigger>-</CssField.DecrementTrigger>
// <CssField.Input />
// <CssField.IncrementTrigger>+</CssField.IncrementTrigger>
// </CssField>
import CssFieldComponent from './css-field.svelte';
import Input from './css-field-input.svelte';
import IncrementTrigger from './css-field-increment-trigger.svelte';
import DecrementTrigger from './css-field-decrement-trigger.svelte';
import Scrubber from './css-field-scrubber.svelte';
type CssFieldNamespace = typeof CssFieldComponent & {
Input: typeof Input;
IncrementTrigger: typeof IncrementTrigger;
DecrementTrigger: typeof DecrementTrigger;
Scrubber: typeof Scrubber;
};
const CssField = CssFieldComponent as CssFieldNamespace;
CssField.Input = Input;
CssField.IncrementTrigger = IncrementTrigger;
CssField.DecrementTrigger = DecrementTrigger;
CssField.Scrubber = Scrubber;
export { CssField };
export default CssField;
export type {
CssUnit,
CssFieldValidate,
CssFieldProps,
CssFieldInputProps as InputProps,
CssFieldIncrementTriggerProps as IncrementTriggerProps,
CssFieldDecrementTriggerProps as DecrementTriggerProps,
CssFieldScrubberProps as ScrubberProps,
CssFieldSize,
CssFieldVariant,
CssFieldColor
} from './types';

@ -0,0 +1,36 @@
import type {
CssUnit,
CssFieldValidate,
DecrementTriggerProps,
IncrementTriggerProps,
InputProps,
ProviderProps,
ScrubberProps
} from '$soma/components/css-field';
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
export type CssFieldSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
export type CssFieldVariant = ControlVariant;
export type CssFieldColor = ColorRole;
/**
* Props for the eidos `<CssField>`.
*
* Extends Soma's CSS-value spinbutton with visual sizing only. Parsing,
* validation, unit whitelist, scrubbing, keyboard interaction and field
* integration are owned by Soma.
*/
export type CssFieldProps = ProviderProps & {
/** Visual size. Accepts a responsive map resolved by ActiveEidos. @default 'md' */
size?: ResponsiveProp<CssFieldSize>;
/** Surface treatment for the input control. @default 'surface' */
variant?: CssFieldVariant;
/** Accent palette for focus and scrubber active state. @default 'primary' */
color?: CssFieldColor;
};
export type CssFieldInputProps = InputProps;
export type CssFieldIncrementTriggerProps = IncrementTriggerProps;
export type CssFieldDecrementTriggerProps = DecrementTriggerProps;
export type CssFieldScrubberProps = ScrubberProps;
export type { CssUnit, CssFieldValidate };

@ -1262,6 +1262,52 @@
--number-field-transition-duration: var(--duration-fast);
--number-field-transition-ease: var(--ease-default);
--number-field-disabled-opacity: 0.56;
--css-field-height-xs: var(--control-height-xs);
--css-field-height-sm: var(--control-height-sm);
--css-field-height-md: var(--control-height-md);
--css-field-height-lg: var(--control-height-lg);
--css-field-height-xl: var(--control-height-xl);
--css-field-px-xs: var(--space-2);
--css-field-px-sm: var(--space-2-5);
--css-field-px-md: var(--space-3);
--css-field-px-lg: var(--space-3-5);
--css-field-px-xl: var(--space-4);
--css-field-gap-xs: var(--space-0-5);
--css-field-gap-sm: var(--space-1);
--css-field-gap-md: var(--space-1-5);
--css-field-gap-lg: var(--space-2);
--css-field-gap-xl: var(--space-2-5);
--css-field-font-family: var(--font-ui);
--css-field-font-size-xs: var(--font-size-xs);
--css-field-font-size-sm: var(--font-size-xs);
--css-field-font-size-md: var(--font-size-sm);
--css-field-font-size-lg: var(--font-size-sm);
--css-field-font-size-xl: var(--font-size-md);
--css-field-line-height: var(--leading-ui);
--css-field-radius: var(--radius-md);
--css-field-border-width: var(--border-width);
--css-field-border: var(--color-border-default);
--css-field-border-focus: var(--color-focus-ring);
--css-field-border-invalid: var(--color-risk-border);
--css-field-bg: var(--color-surface-default);
--css-field-color: var(--color-content-primary);
--css-field-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--css-field-control-radius: var(--radius-md);
--css-field-control-border-width: var(--border-width);
--css-field-control-border: transparent;
--css-field-control-border-hover: var(--color-border-subtle);
--css-field-control-border-active: var(--color-primary-border);
--css-field-control-bg: transparent;
--css-field-control-bg-hover: var(--color-surface-raised);
--css-field-control-bg-active: var(--color-primary-track);
--css-field-control-color: var(--color-content-secondary);
--css-field-control-color-hover: var(--color-content-primary);
--css-field-control-color-active: var(--color-primary-text);
--css-field-control-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--css-field-control-disabled-opacity: 0.48;
--css-field-transition-duration: var(--duration-fast);
--css-field-transition-ease: var(--ease-default);
--css-field-disabled-opacity: 0.56;
--date-field-stack-gap: var(--space-1-5);
--date-field-height-xs: var(--control-height-xs);
--date-field-height-sm: var(--control-height-sm);

@ -1092,6 +1092,56 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.56'
},
'css-field': {
'height-xs': 'var(--control-height-xs)',
'height-sm': 'var(--control-height-sm)',
'height-md': 'var(--control-height-md)',
'height-lg': 'var(--control-height-lg)',
'height-xl': 'var(--control-height-xl)',
'px-xs': 'var(--space-2)',
'px-sm': 'var(--space-2-5)',
'px-md': 'var(--space-3)',
'px-lg': 'var(--space-3-5)',
'px-xl': 'var(--space-4)',
'gap-xs': 'var(--space-0-5)',
'gap-sm': 'var(--space-1)',
'gap-md': 'var(--space-1-5)',
'gap-lg': 'var(--space-2)',
'gap-xl': 'var(--space-2-5)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'font-size-xl': 'var(--font-size-md)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
border: 'var(--color-border-default)',
'border-focus': 'var(--color-focus-ring)',
'border-invalid': 'var(--color-risk-border)',
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'control-radius': 'var(--radius-md)',
'control-border-width': 'var(--border-width)',
'control-border': 'transparent',
'control-border-hover': 'var(--color-border-subtle)',
'control-border-active': 'var(--color-primary-border)',
'control-bg': 'transparent',
'control-bg-hover': 'var(--color-surface-raised)',
'control-bg-active': 'var(--color-primary-track)',
'control-color': 'var(--color-content-secondary)',
'control-color-hover': 'var(--color-content-primary)',
'control-color-active': 'var(--color-primary-text)',
'control-focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'control-disabled-opacity': '0.48',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.56'
},
'date-field': {
'stack-gap': 'var(--space-1-5)',
'height-xs': 'var(--control-height-xs)',

@ -0,0 +1,178 @@
import type { Morfo } from '../types';
import { v } from '../types';
export const cssFieldMorfo = {
name: 'CssField',
kebab: 'css-field',
scope: ['soma', 'sema'],
texts: {
label: '#?components.css-field.label|CSS Value',
invalid: '#?components.css-field.invalid|Invalid CSS value'
},
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/',
events: [
{
name: 'commit-set',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
}
},
{
name: 'handle-pick',
semantic: {
family: 'handle',
verb: 'pick',
target: v.partRef('scrubber'),
sequence: 'pre',
channels: ['sound', 'haptic']
}
},
{
name: 'handle-drag-scrub',
semantic: {
family: 'handle',
verb: 'drag',
target: v.partRef('scrubber'),
sequence: 'coincident',
channels: ['haptic']
}
},
{
// Book §6.2 — signal.risk = untilFix. Typing a value whose unit is not
// in `values` (or otherwise unparseable) keeps the warning visible until
// the user fixes the input. Provider clears via `runtime.clearTarget(input)`
// before each commit/blur validation pass — the next valid value is the fix.
name: 'signal-warn-invalid',
semantic: {
family: 'signal',
verb: 'warn',
target: v.partRef('input'),
intent: 'risk',
sequence: 'post',
persistence: 'untilFix'
},
a11ySemantic: {
requiresPersistentTrace: true,
reducedMotionFallback: 'text'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
{ attr: 'data-focused', value: v.propRef('focused'), severity: 'optional' },
{ attr: 'data-scrubbing', value: v.propRef('scrubbing'), severity: 'optional' }
],
aria: []
},
{
name: 'Input',
kebab: 'input',
archetype: 'input',
kind: 'public',
defaultElement: 'input',
role: 'spinbutton',
optional: false,
data: [
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' }
],
aria: [
{ attr: 'aria-valuenow', value: v.propRef('value'), severity: 'optional' },
{ attr: 'aria-valuetext', value: v.propRef('value'), severity: 'recommended' },
{ attr: 'aria-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'aria-invalid', value: v.propRef('invalid'), severity: 'optional' }
],
keyboard: [
{ key: 'ArrowUp', action: 'increment' },
{ key: 'ArrowDown', action: 'decrement' },
{ key: 'Shift+ArrowUp', action: 'increment-large' },
{ key: 'Shift+ArrowDown', action: 'decrement-large' },
{ key: 'PageUp', action: 'increment-large' },
{ key: 'PageDown', action: 'decrement-large' },
{ key: 'Home', action: 'first-item' },
{ key: 'End', action: 'last-item' },
{ key: 'Enter', action: 'commit' }
]
},
{
name: 'IncrementTrigger',
kebab: 'increment-trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.commonRef('buttons.increment', 'Increment'),
severity: 'recommended'
},
{
attr: 'aria-disabled',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
]
},
{
name: 'DecrementTrigger',
kebab: 'decrement-trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.commonRef('buttons.decrement', 'Decrement'),
severity: 'recommended'
},
{
attr: 'aria-disabled',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
]
},
{
name: 'Scrubber',
kebab: 'scrubber',
archetype: 'thumb',
kind: 'public',
defaultElement: 'div',
role: 'presentation',
optional: true,
data: [
{ attr: 'data-scrubbing', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
}
]
} as const satisfies Morfo;

@ -0,0 +1,52 @@
import { semaSelector } from '$uix/morfo';
import { cssFieldMorfo } from '$uix/morfo/components/css-field';
import { sound, soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* CssField perceptual defaults.
*
* Commit covers keyboard, trigger, wheel, scrub-release and blur/Enter value
* commits. The scrubber behaves like a direct-manipulation handle (audible
* pickup, haptic-only movement). A disallowed unit / unparseable value emits a
* descending warn cue (untilFix).
*/
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof cssFieldMorfo>>[2]) =>
semaSelector(cssFieldMorfo, 'provider', matchers);
const onInput = (matchers?: Parameters<typeof semaSelector<typeof cssFieldMorfo>>[2]) =>
semaSelector(cssFieldMorfo, 'input', matchers);
const onScrubber = (matchers?: Parameters<typeof semaSelector<typeof cssFieldMorfo>>[2]) =>
semaSelector(cssFieldMorfo, 'scrubber', matchers);
export const cssFieldSema: Sema = {
name: 'css-field',
cascade: [
{
selector: onProvider({ eventName: 'commit-set' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' }
},
{
selector: onScrubber({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: sound('handle.pickup.air', { gain: 0.1 }),
haptic: { kind: 'tick', intensity: 0.18, duration: 6 }
},
{
selector: onScrubber({ eventName: 'handle-drag-scrub' }),
channels: ['haptic'],
haptic: { kind: 'tick', intensity: 0.08, duration: 4 }
},
{
selector: onInput({ eventName: 'signal-warn-invalid' }),
sound: soundTuning('form.commit.soft', {
contour: 'descending',
pitch: { op: 'add', value: -120 }
}),
haptic: { kind: 'warning' }
}
]
};

@ -15,6 +15,7 @@ export { collapsibleSema } from './collapsible';
export { colorPickerSema } from './color-picker';
export { comboboxSema } from './combobox';
export { contextMenuSema } from './context-menu';
export { cssFieldSema } from './css-field';
export { dateFieldSema } from './date-field';
export { dialogSema } from './dialog';
export { drawerSema } from './drawer';

@ -0,0 +1,141 @@
# CssField (soma)
Headless spinbutton for **CSS dimension values** (`12px`, `1.5rem`, `50%`, `-8px`,
`auto`, …). Same behavior surface as `number-field` — keyboard stepping, optional
increment/decrement triggers, pointer scrubber, wheel, form integration, `Field`
inheritance — but it parses and validates a value+unit string instead of a number.
Implements the WAI-ARIA [Spinbutton](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/) pattern.
## Anatomy
```svelte
<CssField.Provider bind:value allowedUnits={['px', 'rem', '%']}>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
<CssField.Scrubber />
</CssField.Provider>
```
`Input` is required; `IncrementTrigger`, `DecrementTrigger` and `Scrubber` are optional.
## Validation — built-in functions OR delegated to sium
The field never uses the sium **engine** internally. By default it validates with the
pure sium **functions** (`parseCssValue` / `isCssValue` from `$sium`) against the
`allowedUnits` whitelist + `keywords` + `allowNegative`. A consumer can **delegate** validation
by passing `validate`:
```svelte
<!-- built-in function validation (default) -->
<CssField allowedUnits={['px', 'rem']} bind:value />
<!-- delegate to a custom predicate -->
<CssField validate={(v) => v.endsWith('px') || 'Only px allowed'} bind:value />
<!-- delegate to a sium schema -->
<CssField validate={pipe(string(), cssValue({ units: ['px', 'rem'] }))} bind:value />
```
Parsing (`parseCssValue` → `{ value, unit }`) always runs regardless of validator — it
feeds increment/decrement/scrub, which step the numeric component and preserve the unit.
An invalid value (disallowed unit / unparseable / failed delegate) is **non-blocking**:
the text is kept, `data-invalid` is set, a `signal-warn-invalid` cue fires (`untilFix`),
and the bound `value` stays at the last valid string. The next valid commit clears it.
## Props (Provider)
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` (bindable) | `undefined` | Current CSS value, e.g. `"12px"`. |
| `allowedUnits` | `CssUnit[]` | all units | Allowed-units whitelist; first = default unit. |
| `keywords` | `string[]` | `[]` | Opt-in bare keywords (`auto`, `inherit`…). |
| `allowNegative` | `boolean` | `true` | Allow negative values. |
| `validate` | `CssFieldValidate` | — | Predicate function **or** sium schema. |
| `min` / `max` | `number` | — | Numeric-component bounds (unit-agnostic). |
| `step` | `number` | per-unit | Step amount (px:1, rem/em:0.1, else 1). |
| `largeStep` | `number` | `step*10` | Shift+Arrow / PageUp-Down amount. |
| `allowMouseWheel` | `boolean` | `false` | Wheel steps when focused. |
| `clampOnBlur` | `boolean` | `true` | Clamp numeric to min/max on blur. |
| `dir` | `'ltr' \| 'rtl'` | prefs | Direction (mirrors scrubber). |
| `disabled` / `readonly` / `required` / `invalid` | `boolean` | `false` | Form state. |
| `name` | `string` | — | Hidden form input name. |
| `onValueChange` | `OnChangeFn<string \| undefined>` | — | On every change. |
| `onValueCommit` | `OnChangeFn<string>` | — | On blur/Enter with a valid value. |
| `onInvalid` | `(value: string) => void` | — | With the rejected text. |
**Scrubber**: `direction?: 'horizontal' \| 'vertical'` (default `'horizontal'`), `sensitivity?: number` (default `5`).
## Data attributes
- Provider: `data-css-field`, `data-disabled`, `data-readonly`, `data-required`, `data-invalid`, `data-focused`, `data-scrubbing`
- Input: `data-css-field-input`, `data-disabled`, `data-readonly`, `data-invalid`
- Triggers: `data-css-field-increment-trigger` / `data-css-field-decrement-trigger`, `data-disabled`
- Scrubber: `data-css-field-scrubber`, `data-scrubbing`, `data-disabled`
## Keyboard (Input)
| Key | Action |
| --- | --- |
| ↑ / ↓ | Increment / decrement by `step` |
| Shift+↑ / Shift+↓, PageUp / PageDown | Increment / decrement by `largeStep` |
| Home / End | Set to `min` / `max` (keeps unit) |
| Enter | Commit the typed value |
## Sema events
| Event | Family · Verb | Target | Intent | Sequence | When |
| --- | --- | --- | --- | --- | --- |
| `commit-set` | commit · set | provider | neutral | post | Value committed (keyboard/trigger/wheel/blur/Enter/scrub-release) |
| `handle-pick` | handle · pick | scrubber | — | pre | Scrub pickup |
| `handle-drag-scrub` | handle · drag | scrubber | — | coincident | Scrub movement (haptic only) |
| `signal-warn-invalid` | signal · warn | input | risk | post | Invalid value typed (`persistence: untilFix`) |
Pack: `src/uix/sema/components/css-field.ts`.
## Field integration
Inside `<Field>`, inherits `disabled` / `readonly` / `required` / `invalid`, registers its
input id into `field.inputId` (direct assignment, A30) and joins `helper`/`error` ids into
`aria-describedby`.
## Baseline
No Air baseline — new component. Behavior surface cloned from `number-field`; validation
helpers added to `$sium` (`parseCssValue`, `formatCssValue`, `isCssValue`, `cssValue`).
## Comparativa
| Feature | Radix | Ark UI | React Aria | CssField |
| --- | --- | --- | --- | --- |
| Numeric stepper | NumberField | NumberInput | NumberField | ✓ (numeric component) |
| Unit-aware value | ✗ | ✗ | ✗ | ✓ (`allowedUnits` whitelist) |
| Increment/decrement triggers | ✓ | ✓ | ✓ | ✓ (optional) |
| Pointer scrubber | ✗ | ✗ | ✗ | ✓ (optional) |
| Pluggable validation | ✗ | partial | partial | ✓ (function or sium schema) |
No reference lib ships a unit-aware CSS value field; the closest analogs are numeric
spinbuttons. The unit whitelist + scrubber + delegated validation are the differentiators.
## Decisiones
- **`value` is a string** (`"12px"`) — natural for CSS authoring; parsed internally.
- **`allowedUnits` = unit whitelist**. First entry is the default unit.
- **sium functions, not the engine**, by default; the engine is only used when a sium schema
is delegated via `validate`.
- **Invalid is non-blocking** (`untilFix`) rather than rejecting keystrokes — multi-character
units make character-level rejection hostile.
- **Negatives allowed by default** (margins/positions).
## Gaps (out of scope)
- `calc(...)` expressions — deferred (loose validation only would be needed). _diferir_
- Per-unit conversion for `min`/`max` (bounds are unit-agnostic numeric) — _descartar_.
- Color values — separate `color-field` already covers that domain — _descartar_.
## Subset
- `color`: the 8 canonical roles (eidos layer).
- `intent`: only `risk` is emitted (the warn cue); the field carries no evaluative intent otherwise.

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CssFieldDecrementTriggerProvider } from '../css-field-provider.svelte';
import type { CssFieldDecrementTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'css-field-decrement'),
children,
child,
...restProps
}: CssFieldDecrementTriggerProps = $props();
const state = CssFieldDecrementTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CssFieldIncrementTriggerProvider } from '../css-field-provider.svelte';
import type { CssFieldIncrementTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'css-field-increment'),
children,
child,
...restProps
}: CssFieldIncrementTriggerProps = $props();
const state = CssFieldIncrementTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CssFieldInputProvider } from '../css-field-provider.svelte';
import type { CssFieldInputProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'css-field-input'),
children,
child,
...restProps
}: CssFieldInputProps = $props();
const state = CssFieldInputProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<input {...mergedProps} />
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CssFieldScrubberProvider } from '../css-field-provider.svelte';
import type { CssFieldScrubberProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'css-field-scrubber'),
direction = 'horizontal',
sensitivity = 5,
children,
child,
...restProps
}: CssFieldScrubberProps = $props();
const state = CssFieldScrubberProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
direction: readableActive(() => direction),
sensitivity: readableActive(() => sensitivity)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}

@ -0,0 +1,87 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CssFieldProvider } from '../css-field-provider.svelte';
import type { CssFieldProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'css-field'),
value = $bindable(),
allowedUnits,
keywords,
allowNegative = true,
validate,
min,
max,
step,
largeStep,
allowMouseWheel = false,
clampOnBlur = true,
dir,
disabled = false,
readonly: readonlyProp = false,
required = false,
invalid = false,
name,
onValueChange,
onValueCommit,
onInvalid,
children,
child,
...restProps
}: CssFieldProps = $props();
const state = CssFieldProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange?.(v);
}
),
allowedUnits: readableActive(() => allowedUnits),
keywords: readableActive(() => keywords),
allowNegative: readableActive(() => allowNegative),
validate: readableActive(() => validate),
min: readableActive(() => min),
max: readableActive(() => max),
step: readableActive(() => step),
largeStep: readableActive(() => largeStep),
dir: readableActive(() => dir),
clampOnBlur: readableActive(() => clampOnBlur),
allowMouseWheel: readableActive(() => allowMouseWheel),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonlyProp),
required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name),
onValueCommit: readableActive(() => onValueCommit),
onInvalid: readableActive(() => onInvalid)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>{@render children?.()}</div>
{/if}
{#if name}
<input
type="hidden"
{name}
value={value ?? ''}
disabled={disabled || undefined}
required={required || undefined}
/>
{/if}

@ -0,0 +1,285 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { createActiveTimers } from '$timer/active-timers.svelte';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { pipe, string, cssValue } from '$sium';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { FieldProvider } from '../field';
import type { CssFieldValidate } from './types';
import {
CssFieldInputProvider,
CssFieldProvider,
CssFieldScrubberProvider
} from './css-field-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const timers = createActiveTimers();
const disposeDom = dom.dispose.bind(dom);
dom.dispose = () => {
timers.dispose();
disposeDom();
};
const soma = {
dom,
uix: { timers },
langs: {
getLocale: () => 'en-US',
ts: (key: string) => key
},
prefs: {
getDir: () => 'ltr'
},
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
vi.spyOn(CssFieldProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function createCssFieldOpts() {
return {
id: state('css-field-root'),
ref: state<HTMLElement | null>(null),
value: state<string | undefined>(undefined),
allowedUnits: state<readonly string[] | undefined>(undefined),
keywords: state<readonly string[] | undefined>(undefined),
allowNegative: state(true),
validate: state<CssFieldValidate | undefined>(undefined),
min: state<number | undefined>(undefined),
max: state<number | undefined>(undefined),
step: state<number | undefined>(undefined),
largeStep: state<number | undefined>(undefined),
dir: state<'ltr' | 'rtl' | undefined>(undefined),
clampOnBlur: state(true),
allowMouseWheel: state(false),
disabled: state(false),
readonly: state(false),
required: state(false),
invalid: state(false),
name: state<string | undefined>(undefined),
onValueCommit: state<((value: string) => void) | undefined>(undefined),
onInvalid: state<((value: string) => void) | undefined>(undefined)
};
}
function typeAndBlur(provider: CssFieldProvider, text: string) {
provider.handleFocus({} as never);
const input = document.createElement('input');
input.value = text;
provider.handleInput({ currentTarget: input } as never);
provider.handleBlur({} as never);
}
describe('CssFieldProvider', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('parses and commits a value+unit on blur', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
const onCommit = vi.fn();
opts.onValueCommit.current = onCommit;
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
typeAndBlur(provider, '2rem');
expect(opts.value.current).toBe('2rem');
expect(onCommit).toHaveBeenCalledWith('2rem');
cleanup();
dom.dispose();
});
it('increments preserving the unit, with a per-unit default step', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
opts.value.current = '16px';
provider.handleKeydown({ key: 'ArrowUp', shiftKey: false, preventDefault: vi.fn() } as never);
expect(opts.value.current).toBe('17px');
opts.value.current = '1rem';
provider.handleKeydown({ key: 'ArrowUp', shiftKey: false, preventDefault: vi.fn() } as never);
expect(opts.value.current).toBe('1.1rem');
cleanup();
dom.dispose();
});
it('attaches the default unit to a bare number on commit', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
opts.allowedUnits.current = ['px'];
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
typeAndBlur(provider, '12');
expect(opts.value.current).toBe('12px');
cleanup();
dom.dispose();
});
it('marks invalid (untilFix) and keeps the last value on a disallowed unit', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
const onInvalid = vi.fn();
opts.allowedUnits.current = ['px'];
opts.value.current = '8px';
opts.onInvalid.current = onInvalid;
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
typeAndBlur(provider, '5rem');
expect(provider.internalInvalid).toBe(true);
expect(provider.isInvalid).toBe(true);
expect(opts.value.current).toBe('8px');
expect(onInvalid).toHaveBeenCalledWith('5rem');
cleanup();
dom.dispose();
});
it('accepts opt-in keywords', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
opts.allowedUnits.current = ['px', '%'];
opts.keywords.current = ['auto'];
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
typeAndBlur(provider, 'auto');
expect(opts.value.current).toBe('auto');
expect(provider.internalInvalid).toBe(false);
cleanup();
dom.dispose();
});
it('clamps the numeric component to min/max on blur', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
opts.min.current = 0;
opts.max.current = 10;
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
typeAndBlur(provider, '20px');
expect(opts.value.current).toBe('10px');
cleanup();
dom.dispose();
});
it('delegates validation to a sium schema when provided', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
opts.value.current = '4px';
opts.validate.current = pipe(string(), cssValue({ units: ['px'] })) as never;
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
typeAndBlur(provider, '4rem');
expect(provider.internalInvalid).toBe(true);
expect(opts.value.current).toBe('4px');
typeAndBlur(provider, '6px');
expect(provider.internalInvalid).toBe(false);
expect(opts.value.current).toBe('6px');
cleanup();
dom.dispose();
});
it('scrubs the numeric component preserving the unit', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
const onCommit = vi.fn();
opts.value.current = '10px';
opts.step.current = 2;
opts.dir.current = 'ltr';
opts.onValueCommit.current = onCommit;
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
vi.spyOn(CssFieldProvider.ctx, 'get').mockReturnValue(provider);
const scrubber = CssFieldScrubberProvider.create({
id: state('css-field-scrubber'),
ref: state<HTMLElement | null>(null),
direction: state<'horizontal' | 'vertical'>('horizontal'),
sensitivity: state(5)
});
const target = { setPointerCapture: vi.fn() };
scrubber.onpointerdown({ button: 0, clientX: 0, clientY: 0, preventDefault: vi.fn() } as never);
scrubber.onpointermove({
clientX: 15,
clientY: 0,
pointerId: 1,
currentTarget: target,
preventDefault: vi.fn()
} as never);
expect(provider.scrubbing).toBe(true);
expect(opts.value.current).toBe('16px');
scrubber.onpointerup();
expect(onCommit).toHaveBeenCalledWith('16px');
cleanup();
dom.dispose();
});
it('exposes spinbutton input props (valuenow numeric, valuetext full string)', () => {
const { dom } = installSomaHarness();
const opts = createCssFieldOpts();
opts.value.current = '24px';
opts.required.current = true;
const { result: provider, cleanup } = withEffectRoot(() => CssFieldProvider.create(opts));
vi.spyOn(CssFieldProvider.ctx, 'get').mockReturnValue(provider);
const input = CssFieldInputProvider.create({
id: state('css-field-input'),
ref: state<HTMLElement | null>(null)
});
expect(input.props).toMatchObject({
role: 'spinbutton',
value: '24px',
'aria-valuenow': 24,
'aria-valuetext': '24px',
'aria-required': 'true'
});
cleanup();
dom.dispose();
});
});

@ -0,0 +1,834 @@
import { watch } from 'runed';
import { context, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef, boolToStr, isHTMLElement } from '$adom';
import { readableActive, type Active, type ActiveProps, type StateProps } from '$libs/reactive';
import type {
OnChangeFn,
Direction,
SomaKeyboardEvent,
SomaFocusEvent,
SomaInputEvent
} from '../../types';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { CSS_FIELD_LANGS } from './langs';
import { FieldProvider } from '../field';
import type { CssFieldValidate } from './types';
// Pure sium FUNCTIONS only — never the schema engine. The field validates with
// these by default; a sium schema can be delegated via the `validate` prop.
import { parseCssValue, formatCssValue } from '$sium';
import { cssFieldMorfo } from '../../../morfo/components/css-field';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { TimerHandle, TimerScheduler } from '$libs/timer';
// ── Helpers ──────────────────────────────────────────────────────────────────
function getDecimalPrecision(n: number): number {
const str = String(n);
const dot = str.indexOf('.');
return dot >= 0 ? str.length - dot - 1 : 0;
}
/** Default per-unit step when `step` is not supplied. */
const DEFAULT_STEP_BY_UNIT: Record<string, number> = { rem: 0.1, em: 0.1 };
function defaultStepFor(unit: string): number {
return DEFAULT_STEP_BY_UNIT[unit] ?? 1;
}
const BARE_NUMBER_REGEX = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/;
const SPIN_PRESS_DELAY_MS = 400;
const SPIN_PRESS_INTERVAL_MS = 100;
class SpinPressRepeater {
private delayTimer: TimerHandle | null = null;
private intervalTimer: TimerHandle | null = null;
private handledPointerPress = false;
constructor(
private readonly timers: TimerScheduler,
private readonly key: string,
private readonly isDisabled: () => boolean,
private readonly step: () => void
) {}
start(): void {
this.stop();
if (this.isDisabled()) return;
this.handledPointerPress = true;
this.step();
this.delayTimer = this.timers.schedule(
`${this.key}:delay`,
SPIN_PRESS_DELAY_MS,
() => {
this.intervalTimer = this.timers.interval(
`${this.key}:interval`,
SPIN_PRESS_INTERVAL_MS,
() => {
if (this.isDisabled()) {
this.stop();
return;
}
this.step();
},
{ replace: true, awaitTask: false }
);
},
{ replace: true }
);
}
stop(): void {
if (this.delayTimer) {
this.delayTimer.cancel();
this.delayTimer = null;
}
if (this.intervalTimer) {
this.intervalTimer.cancel();
this.intervalTimer = null;
}
}
consumePointerClick(): boolean {
if (!this.handledPointerPress) return false;
this.handledPointerPress = false;
return true;
}
}
function createSpinPressRepeater(
timers: TimerScheduler,
key: string,
isDisabled: () => boolean,
step: () => void
): SpinPressRepeater {
return new SpinPressRepeater(timers, key, isDisabled, step);
}
// ── Root ─────────────────────────────────────────────────────────────────────
interface CssFieldOpts
extends
WithRefOpts,
StateProps<{ value: string | undefined }>,
ActiveProps<{
allowedUnits: readonly string[] | undefined;
keywords: readonly string[] | undefined;
allowNegative: boolean;
validate: CssFieldValidate | undefined;
min: number | undefined;
max: number | undefined;
step: number | undefined;
largeStep: number | undefined;
dir: Direction | undefined;
clampOnBlur: boolean;
allowMouseWheel: boolean;
disabled: boolean;
readonly: boolean;
required: boolean;
invalid: boolean;
name: string | undefined;
onValueCommit: OnChangeFn<string> | undefined;
onInvalid: ((value: string) => void) | undefined;
}> {}
export class CssFieldProvider {
readonly opts: CssFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<CssFieldProvider>('CssField');
static get(): CssFieldProvider | undefined {
return this.ctx.getOr(undefined) as CssFieldProvider | undefined;
}
static require(): CssFieldProvider {
return this.ctx.get();
}
static create(opts: CssFieldOpts) {
return new CssFieldProvider(opts);
}
/** Optional parent Field — inherits disabled/readonly/required/invalid + input id wiring. */
readonly field = FieldProvider.get();
// Input display value (the editable text = the canonical CSS string).
inputValue = $state('');
focused = $state(false);
// Validity owned by the field (unparseable / disallowed unit / failed delegate).
internalInvalid = $state(false);
// Scrub state
scrubbing = $state(false);
private pendingCommit: { value: string | undefined; clamp: boolean } | undefined;
/** Resolved direction: prop → Soma.prefs.getDir() → 'ltr'. */
readonly resolvedDir: Active<Direction>;
private constructor(opts: CssFieldOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(cssFieldMorfo, {
props: {
disabled: () => this.isDisabled,
readonly: () => this.isReadonly,
required: () => this.isRequired,
invalid: () => this.isInvalid,
focused: () => this.focused,
scrubbing: () => this.scrubbing
},
events: {
'commit-set': () => this.applyPendingCommit()
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: CssFieldProvider.ctx,
syncAttrs: true
});
this.resolvedDir = readableActive(() => opts.dir.current ?? this.soma.prefs.getDir() ?? 'ltr');
// Sync the visible text with external value changes while not editing.
watch.pre(
() => this.opts.value.current,
(value) => {
if (this.focused) return;
this.inputValue = value ?? '';
}
);
}
// ── Computed ──────────────────────────────────────────────────────────────
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
);
readonly isReadonly = $derived.by(
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
);
readonly isRequired = $derived.by(
() => this.opts.required.current || (this.field?.isRequired ?? false)
);
readonly isInvalid = $derived.by(
() =>
this.opts.invalid.current || (this.field?.isInvalid ?? false) || this.internalInvalid
);
readonly dir = $derived.by(() => this.resolvedDir.current);
readonly min = $derived.by(() => this.opts.min.current);
readonly max = $derived.by(() => this.opts.max.current);
/** Parse options derived from the field's unit whitelist + keywords + sign. */
readonly parseOptions = $derived.by(() => ({
units: this.opts.allowedUnits.current,
keywords: this.opts.keywords.current ?? [],
allowNegative: this.opts.allowNegative.current
}));
/** Default unit applied to a bare number / increment-from-empty. */
readonly defaultUnit = $derived.by(() => this.opts.allowedUnits.current?.[0] ?? 'px');
/** The current value parsed into its numeric + unit parts (or keyword / null). */
readonly parsed = $derived.by(() => parseCssValue(this.opts.value.current, this.parseOptions));
/** The unit currently in effect (for stepping/scrubbing). */
readonly resolvedUnit = $derived.by(() => {
const p = this.parsed;
return p && p.kind === 'dimension' && p.unit ? p.unit : this.defaultUnit;
});
readonly step = $derived.by(() => this.opts.step.current ?? defaultStepFor(this.resolvedUnit));
readonly largeStep = $derived.by(() => this.opts.largeStep.current ?? this.step * 10);
readonly atMin = $derived.by(() => {
const p = this.parsed;
return this.min !== undefined && p?.kind === 'dimension' && p.value <= this.min;
});
readonly atMax = $derived.by(() => {
const p = this.parsed;
return this.max !== undefined && p?.kind === 'dimension' && p.value >= this.max;
});
// ── Validity (ambivalent: built-in functions OR delegated sium schema) ─────
/** Built-in or delegated validity for a canonical value string. */
private checkValidity(value: string): { valid: boolean; message?: string } {
const validate = this.opts.validate.current;
if (validate) {
if (typeof validate === 'function') {
const r = validate(value);
if (r === true || r === undefined) return { valid: true };
if (r === false) return { valid: false };
return { valid: false, message: r };
}
const r = validate.validateSync(value);
return r.ok ? { valid: true } : { valid: false, message: r.issues?.[0]?.message };
}
// Built-in: pure sium functions, no engine.
return { valid: parseCssValue(value, this.parseOptions) !== null };
}
/** Normalize a raw input to a canonical CSS string and validate it. */
private normalizeAndValidate(
raw: string
): { ok: true; value: string } | { ok: false; message?: string } {
let canonical = raw.trim();
const parts = parseCssValue(canonical, this.parseOptions);
if (parts) {
canonical = formatCssValue(parts);
} else if (BARE_NUMBER_REGEX.test(canonical)) {
// Bare number → attach the default unit (e.g. "12" → "12px").
const n = Number(canonical);
if (Number.isFinite(n) && (this.opts.allowNegative.current || n >= 0)) {
canonical = formatCssValue({ kind: 'dimension', value: n, unit: this.defaultUnit });
}
}
const check = this.checkValidity(canonical);
if (!check.valid) return { ok: false, message: check.message };
return { ok: true, value: canonical };
}
// ── Number helpers (operate on the numeric component) ──────────────────────
clampNumber(n: number): number {
let v = n;
if (this.min !== undefined) v = Math.max(this.min, v);
if (this.max !== undefined) v = Math.min(this.max, v);
return v;
}
roundNumber(n: number, step: number): number {
const precision = getDecimalPrecision(step);
const factor = Math.pow(10, precision);
return Math.round(n * factor) / factor;
}
/** Build a canonical value string from a number + the active unit. */
private toValue(n: number, unit: string): string {
return formatCssValue({ kind: 'dimension', value: n, unit: unit || this.defaultUnit });
}
// ── Commit / invalid ───────────────────────────────────────────────────────
private applyCommit(value: string | undefined, clamp: boolean): void {
if (value === undefined) {
this.opts.value.current = undefined;
this.inputValue = '';
this.clearInvalid();
return;
}
let finalStr = value;
const parts = parseCssValue(value, this.parseOptions);
if (clamp && parts && parts.kind === 'dimension') {
const clamped = this.clampNumber(parts.value);
if (clamped !== parts.value) finalStr = formatCssValue({ ...parts, value: clamped });
}
this.opts.value.current = finalStr;
this.opts.onValueCommit.current?.(finalStr);
this.inputValue = finalStr;
this.clearInvalid();
}
private applyPendingCommit(): void {
const pending = this.pendingCommit;
this.pendingCommit = undefined;
if (!pending) return;
this.applyCommit(pending.value, pending.clamp);
}
commit(
value: string | undefined,
opts: { clamp?: boolean; fallbackTarget?: HTMLElement } = {}
): void {
this.pendingCommit = { value, clamp: opts.clamp ?? true };
void this.runtime.trigger(
'commit-set',
opts.fallbackTarget ? { fallbackTarget: opts.fallbackTarget } : undefined
);
}
/** Book §6.2 untilFix — mark invalid + emit a warn signal until the next valid commit. */
private markInvalid(raw: string, message?: string): void {
this.internalInvalid = true;
this.opts.onInvalid.current?.(raw);
const input = this.runtime.partRef('input');
if (input) this.runtime.clearTarget(input);
const text =
message && message.startsWith('#?')
? this.soma.langs.ts(message)
: (message ?? this.soma.langs.ts(CSS_FIELD_LANGS.INVALID));
void this.runtime.trigger('signal-warn-invalid', {
...(input ? { fallbackTarget: input } : {}),
message: text
});
}
private clearInvalid(): void {
if (!this.internalInvalid) return;
this.internalInvalid = false;
const input = this.runtime.partRef('input');
if (input) this.runtime.clearTarget(input);
}
// ── Actions ──────────────────────────────────────────────────────────────
increment(by: number = this.step): void {
if (this.isDisabled || this.isReadonly) return;
const p = this.parsed;
const baseValue = p && p.kind === 'dimension' ? p.value : (this.min ?? 0);
const unit = p && p.kind === 'dimension' && p.unit ? p.unit : this.defaultUnit;
const next = this.clampNumber(this.roundNumber(baseValue + by, by));
this.commit(this.toValue(next, unit));
}
decrement(by: number = this.step): void {
if (this.isDisabled || this.isReadonly) return;
const p = this.parsed;
const baseValue = p && p.kind === 'dimension' ? p.value : (this.min ?? 0);
const unit = p && p.kind === 'dimension' && p.unit ? p.unit : this.defaultUnit;
const next = this.clampNumber(this.roundNumber(baseValue - by, by));
this.commit(this.toValue(next, unit));
}
// ── Event handlers ───────────────────────────────────────────────────────
readonly handleFocus = (_e: SomaFocusEvent<HTMLInputElement>): void => {
this.focused = true;
this.inputValue = this.opts.value.current ?? '';
};
readonly handleBlur = (_e: SomaFocusEvent<HTMLInputElement>): void => {
this.focused = false;
const raw = this.inputValue.trim();
if (raw === '') {
this.commit(undefined);
return;
}
const res = this.normalizeAndValidate(raw);
if (!res.ok) {
this.markInvalid(raw, res.message);
return;
}
this.commit(res.value, { clamp: this.opts.clampOnBlur.current });
};
readonly handleInput = (e: SomaInputEvent<HTMLInputElement>): void => {
// Accept all text while typing — validity is decided on commit (untilFix).
this.inputValue = e.currentTarget.value;
};
readonly handleWheel = (e: WheelEvent): void => {
if (!this.opts.allowMouseWheel.current || this.isDisabled || this.isReadonly) return;
if (!isHTMLElement(e.currentTarget)) return;
if (this.soma.dom.activeElement(e.currentTarget) !== e.currentTarget) return;
e.preventDefault();
if (e.deltaY < 0) {
this.increment();
} else if (e.deltaY > 0) {
this.decrement();
}
};
readonly handleKeydown = (e: SomaKeyboardEvent<HTMLInputElement>): void => {
if (this.isDisabled || this.isReadonly) return;
switch (e.key) {
case KEYS.ARROW_UP:
e.preventDefault();
this.increment(e.shiftKey ? this.largeStep : this.step);
break;
case KEYS.ARROW_DOWN:
e.preventDefault();
this.decrement(e.shiftKey ? this.largeStep : this.step);
break;
case KEYS.PAGE_UP:
e.preventDefault();
this.increment(this.largeStep);
break;
case KEYS.PAGE_DOWN:
e.preventDefault();
this.decrement(this.largeStep);
break;
case KEYS.HOME:
if (this.min !== undefined) {
e.preventDefault();
this.commit(this.toValue(this.min, this.resolvedUnit));
}
break;
case KEYS.END:
if (this.max !== undefined) {
e.preventDefault();
this.commit(this.toValue(this.max, this.resolvedUnit));
}
break;
case KEYS.ENTER: {
e.preventDefault();
const raw = e.currentTarget.value.trim();
if (raw === '') {
this.commit(undefined);
break;
}
const res = this.normalizeAndValidate(raw);
if (!res.ok) this.markInvalid(raw, res.message);
else this.commit(res.value, { clamp: this.opts.clampOnBlur.current });
break;
}
}
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
dir: this.dir
} as const)
);
}
// ── Input ────────────────────────────────────────────────────────────────────
interface CssFieldInputOpts extends WithRefOpts {}
export class CssFieldInputProvider {
readonly opts: CssFieldInputOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CssFieldInputOpts) {
return new CssFieldInputProvider(opts);
}
readonly provider: CssFieldProvider;
private constructor(opts: CssFieldInputOpts) {
this.opts = opts;
this.provider = CssFieldProvider.require();
this.runtimePart = this.provider.runtime.part('input', {
id: opts.id,
ref: opts.ref,
owner: this
});
// Register this input id so a parent Field.Label `for=` targets it.
if (this.provider.field) {
this.provider.field.inputId.current = opts.id.current;
}
}
readonly describedBy = $derived.by(() => {
const field = this.provider.field;
if (!field) return undefined;
const ids = [field.helperId.current, field.isInvalid ? field.errorId.current : ''].filter(
Boolean
);
return ids.length > 0 ? ids.join(' ') : undefined;
});
readonly props = $derived.by(() => {
const p = this.provider.parsed;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'spinbutton' as const,
inputmode: 'text' as const,
autocomplete: 'off' as const,
autocorrect: 'off' as const,
spellcheck: false,
dir: this.provider.dir,
value: this.provider.inputValue,
'aria-valuenow': p && p.kind === 'dimension' ? p.value : undefined,
'aria-valuemin': this.provider.opts.min.current,
'aria-valuemax': this.provider.opts.max.current,
'aria-valuetext': this.provider.opts.value.current || undefined,
'aria-required': boolToStr(this.provider.isRequired),
'aria-disabled': boolToStr(this.provider.isDisabled),
'aria-readonly': boolToStr(this.provider.isReadonly),
'aria-invalid': boolToStr(this.provider.isInvalid),
'aria-describedby': this.describedBy,
disabled: this.provider.isDisabled || undefined,
readonly: this.provider.isReadonly || undefined,
required: this.provider.isRequired || undefined,
name: this.provider.opts.name.current || undefined,
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(this.provider.isInvalid),
onfocus: this.provider.handleFocus,
onblur: this.provider.handleBlur,
oninput: this.provider.handleInput,
onkeydown: this.provider.handleKeydown,
onwheel: this.provider.handleWheel
} as const);
});
}
// ── IncrementTrigger ─────────────────────────────────────────────────────────
interface CssFieldIncrementTriggerOpts extends WithRefOpts {}
export class CssFieldIncrementTriggerProvider {
readonly opts: CssFieldIncrementTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CssFieldIncrementTriggerOpts) {
return new CssFieldIncrementTriggerProvider(opts);
}
readonly provider: CssFieldProvider;
readonly repeater: SpinPressRepeater;
private constructor(opts: CssFieldIncrementTriggerOpts) {
this.opts = opts;
this.provider = CssFieldProvider.require();
this.repeater = createSpinPressRepeater(
this.provider.soma.uix.timers,
`css-field:${opts.id.current}:increment`,
() => this.isDisabled,
() => this.provider.increment()
);
this.runtimePart = this.provider.runtime.part('increment-trigger', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMax);
readonly handleClick = () => {
if (this.repeater.consumePointerClick()) return;
this.provider.increment();
};
readonly handlePointerDown = (e: PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
this.repeater.start();
};
readonly stopPress = () => {
this.repeater.stop();
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label': this.provider.soma.langs.ts(CSS_FIELD_LANGS.INCREMENT),
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.handleClick,
onpointerdown: this.handlePointerDown,
onpointerup: this.stopPress,
onpointerleave: this.stopPress,
onpointercancel: this.stopPress,
onlostpointercapture: this.stopPress
} as const)
);
}
// ── DecrementTrigger ─────────────────────────────────────────────────────────
interface CssFieldDecrementTriggerOpts extends WithRefOpts {}
export class CssFieldDecrementTriggerProvider {
readonly opts: CssFieldDecrementTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CssFieldDecrementTriggerOpts) {
return new CssFieldDecrementTriggerProvider(opts);
}
readonly provider: CssFieldProvider;
readonly repeater: SpinPressRepeater;
private constructor(opts: CssFieldDecrementTriggerOpts) {
this.opts = opts;
this.provider = CssFieldProvider.require();
this.repeater = createSpinPressRepeater(
this.provider.soma.uix.timers,
`css-field:${opts.id.current}:decrement`,
() => this.isDisabled,
() => this.provider.decrement()
);
this.runtimePart = this.provider.runtime.part('decrement-trigger', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isDisabled = $derived.by(() => this.provider.isDisabled || this.provider.atMin);
readonly handleClick = () => {
if (this.repeater.consumePointerClick()) return;
this.provider.decrement();
};
readonly handlePointerDown = (e: PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
this.repeater.start();
};
readonly stopPress = () => {
this.repeater.stop();
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
tabindex: -1,
disabled: this.isDisabled || undefined,
'aria-label': this.provider.soma.langs.ts(CSS_FIELD_LANGS.DECREMENT),
'aria-disabled': boolToStr(this.isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.handleClick,
onpointerdown: this.handlePointerDown,
onpointerup: this.stopPress,
onpointerleave: this.stopPress,
onpointercancel: this.stopPress,
onlostpointercapture: this.stopPress
} as const)
);
}
// ── Scrubber ─────────────────────────────────────────────────────────────────
interface CssFieldScrubberOpts
extends
WithRefOpts,
ActiveProps<{
direction: 'horizontal' | 'vertical';
sensitivity: number;
}> {}
export class CssFieldScrubberProvider {
readonly opts: CssFieldScrubberOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: CssFieldScrubberOpts) {
return new CssFieldScrubberProvider(opts);
}
readonly provider: CssFieldProvider;
private startX = 0;
private startY = 0;
private startValue = 0;
private startUnit = 'px';
private scrubStep = 1;
private pointerDown = false;
private captured = false;
private static readonly MOVE_BUFFER = 5;
private constructor(opts: CssFieldScrubberOpts) {
this.opts = opts;
this.provider = CssFieldProvider.require();
this.runtimePart = this.provider.runtime.part('scrubber', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
private get isDisabled(): boolean {
return this.provider.isDisabled || this.provider.isReadonly;
}
readonly onpointerdown = (e: PointerEvent) => {
if (this.isDisabled || e.button !== 0) return;
e.preventDefault();
this.pointerDown = true;
this.captured = false;
this.startX = e.clientX;
this.startY = e.clientY;
const p = this.provider.parsed;
this.startValue = p && p.kind === 'dimension' ? p.value : (this.provider.min ?? 0);
this.startUnit = p && p.kind === 'dimension' && p.unit ? p.unit : this.provider.defaultUnit;
this.scrubStep = this.provider.step;
void this.provider.runtime.trigger('handle-pick', {
fallbackTarget: e.currentTarget as HTMLElement
});
};
readonly onpointermove = (e: PointerEvent) => {
if (!this.pointerDown) return;
if (!this.captured) {
const dx = e.clientX - this.startX;
const dy = e.clientY - this.startY;
if (Math.sqrt(dx * dx + dy * dy) < CssFieldScrubberProvider.MOVE_BUFFER) return;
this.captured = true;
this.provider.scrubbing = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
e.preventDefault();
void this.provider.runtime.trigger('handle-drag-scrub', {
fallbackTarget: e.currentTarget as HTMLElement
});
const sensitivity = this.opts.sensitivity.current;
const isHorizontal = this.opts.direction.current === 'horizontal';
// In RTL, horizontal scrub is mirrored (drag right → decrement).
const isRtlHorizontal = isHorizontal && this.provider.dir === 'rtl';
const rawDelta = isHorizontal ? e.clientX - this.startX : -(e.clientY - this.startY);
const delta = isRtlHorizontal ? -rawDelta : rawDelta;
const steps = Math.round(delta / sensitivity);
const newValue = this.startValue + steps * this.scrubStep;
const clamped = this.provider.clampNumber(this.provider.roundNumber(newValue, this.scrubStep));
// During scrub, update value (onValueChange) — commit (onValueCommit) on release.
const next = formatCssValue({ kind: 'dimension', value: clamped, unit: this.startUnit });
this.provider.opts.value.current = next;
this.provider.inputValue = next;
};
readonly onpointerup = (e?: PointerEvent) => {
this.pointerDown = false;
if (!this.provider.scrubbing) return;
this.captured = false;
this.provider.scrubbing = false;
this.provider.commit(this.provider.opts.value.current, {
fallbackTarget: e?.currentTarget as HTMLElement | undefined
});
};
readonly onlostpointercapture = (e?: PointerEvent) => {
this.pointerDown = false;
this.captured = false;
if (this.provider.scrubbing) {
this.provider.scrubbing = false;
this.provider.commit(this.provider.opts.value.current, {
fallbackTarget: e?.currentTarget as HTMLElement | undefined
});
}
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'presentation' as const,
'aria-hidden': true as const,
'data-scrubbing': boolToEmptyStrOrUndef(this.provider.scrubbing),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
style: {
cursor: this.isDisabled
? 'default'
: this.opts.direction.current === 'horizontal'
? 'ew-resize'
: 'ns-resize',
'user-select': this.provider.scrubbing ? 'none' : undefined,
'touch-action': 'none'
},
onpointerdown: this.onpointerdown,
onpointermove: this.onpointermove,
onpointerup: this.onpointerup,
onlostpointercapture: this.onlostpointercapture
} as const)
);
}

@ -0,0 +1,15 @@
export { default as Provider } from './components/css-field.svelte';
export { default as Input } from './components/css-field-input.svelte';
export { default as IncrementTrigger } from './components/css-field-increment-trigger.svelte';
export { default as DecrementTrigger } from './components/css-field-decrement-trigger.svelte';
export { default as Scrubber } from './components/css-field-scrubber.svelte';
export type {
CssUnit,
CssFieldValidate,
CssFieldProps as ProviderProps,
CssFieldInputProps as InputProps,
CssFieldIncrementTriggerProps as IncrementTriggerProps,
CssFieldDecrementTriggerProps as DecrementTriggerProps,
CssFieldScrubberProps as ScrubberProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,6 @@
/** Idlangref constants for the CssField component. */
export const CSS_FIELD_LANGS = {
INCREMENT: '#?common.buttons.increment|Increment',
DECREMENT: '#?common.buttons.decrement|Decrement',
INVALID: '#?components.css-field.invalid|Invalid CSS value'
} as const;

@ -0,0 +1,115 @@
import type { WithChild, Without, OnChangeFn, Direction } from '../../types';
import type {
PrimitiveDivAttributes,
PrimitiveButtonAttributes,
PrimitiveInputAttributes
} from '../../types';
import type { CssUnit } from '$sium';
export type { CssUnit };
/**
* A sium schema the field can delegate validation to. Any sium schema exposes a
* synchronous `validateSync`. Pass e.g. `pipe(string(), cssValue({ units: ['px'] }))`.
*/
export interface CssFieldSchemaValidator {
validateSync(value: unknown):
| { ok: true }
| { ok: false; issues: ReadonlyArray<{ message: string }> };
}
/**
* Validation strategy for a CssField.
* - A predicate function: `true`/`void` = valid, `false` = invalid, `string` = invalid + message.
* - A sium schema: the field calls `validateSync` and uses the first issue message.
*
* When omitted, the field validates with the built-in pure functions
* (`isCssValue` against `allowedUnits` / `keywords` / `allowNegative`).
*/
export type CssFieldValidate =
| ((value: string) => boolean | string | void)
| CssFieldSchemaValidator;
export type CssFieldProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Current CSS value string, e.g. `"12px"`. Bindable. @default undefined */
value?: string;
/**
* Allowed CSS units (whitelist) for this field, e.g. `['px', 'rem', '%']`.
* The first entry is the default unit applied to a bare number or when
* incrementing from empty. Omit to allow every CSS unit.
*
* NOTE: `allowedUnits` constrains the *units*, it is NOT the bound `value`.
*/
allowedUnits?: CssUnit[];
/** Opt-in bare keywords accepted as values, e.g. `['auto', 'inherit']`. @default [] */
keywords?: string[];
/** Allow negative values (e.g. `-8px`). @default true */
allowNegative?: boolean;
/**
* Validation strategy. A predicate function OR a sium schema to delegate to.
* When omitted, the field validates with the built-in pure functions.
* @see CssFieldValidate
*/
validate?: CssFieldValidate;
/** Minimum numeric component (unit-agnostic). */
min?: number;
/** Maximum numeric component (unit-agnostic). */
max?: number;
/** Increment/decrement amount. Defaults per-unit (px:1, rem/em:0.1, else 1). */
step?: number;
/** Large step for Shift+Arrow or PageUp/PageDown. @default step * 10 */
largeStep?: number;
/** Allow mouse wheel to step the value when focused. @default false */
allowMouseWheel?: boolean;
/** Clamp the numeric component to min/max on blur. @default true */
clampOnBlur?: boolean;
/** Text direction. Resolved via: prop → Soma.prefs.getDir() → 'ltr'. */
dir?: Direction;
/** Disables all interaction. @default false */
disabled?: boolean;
/** Prevents value changes but keeps focusable. @default false */
readonly?: boolean;
/** Marks as required for form validation. @default false */
required?: boolean;
/** Marks as invalid for form validation (combined with internal validity). @default false */
invalid?: boolean;
/** Form submission field name. */
name?: string;
/** Callback fired when value changes. */
onValueChange?: OnChangeFn<string | undefined>;
/** Callback fired when value is committed (blur or Enter). Only fires with a valid value. */
onValueCommit?: OnChangeFn<string>;
/** Callback fired with the rejected text when a typed value fails validation. */
onInvalid?: (value: string) => void;
}> &
Without<PrimitiveDivAttributes, {}>;
export type CssFieldInputProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveInputAttributes, {}>;
export type CssFieldIncrementTriggerProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type CssFieldDecrementTriggerProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveButtonAttributes, {}>;
export type CssFieldScrubberProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Drag direction. @default 'horizontal' */
direction?: 'horizontal' | 'vertical';
/** Pixels per step during scrub. @default 5 */
sensitivity?: number;
}> &
Without<PrimitiveDivAttributes, {}>;

@ -13,6 +13,7 @@ export * as ColorPicker from './color-picker';
export * as Combobox from './combobox';
export * as Command from './command';
export * as ContextMenu from './context-menu';
export * as CssField from './css-field';
export * as DateField from './date-field';
export * as DatePicker from './date-picker';
export * as DateRangeField from './date-range-field';

@ -34,6 +34,7 @@
import { calendarSema } from '$uix/sema/components/calendar';
import { colorPickerSema } from '$uix/sema/components/color-picker';
import { comboboxSema } from '$uix/sema/components/combobox';
import { cssFieldSema } from '$uix/sema/components/css-field';
import { dateFieldSema } from '$uix/sema/components/date-field';
import { Popover } from '$uix/eidos/components/popover';
import '@/uix/eidos/index.css';
@ -69,6 +70,7 @@
checkboxSema,
colorPickerSema,
comboboxSema,
cssFieldSema,
dateFieldSema,
dialogSema,
drawerSema,
@ -152,6 +154,7 @@
{ slug: '/uix/components/editable', label: 'Editable' },
{ slug: '/uix/components/words', label: 'Words' },
{ slug: '/uix/components/number-field', label: 'Number field' },
{ slug: '/uix/components/css-field', label: 'CSS field' },
{ slug: '/uix/components/search-field', label: 'Search field' },
{ slug: '/uix/components/password-field', label: 'Password field' },
{ slug: '/uix/components/textarea', label: 'Text area' },

@ -0,0 +1,688 @@
<script lang="ts">
import {
CssField,
type CssFieldColor,
type CssFieldSize,
type CssFieldVariant,
type CssFieldValidate
} from '$uix/eidos/components/css-field';
import { compileMorfo } from '$uix/morfo';
import { cssFieldMorfo } from '@/uix/morfo/components/css-field';
import { getActiveUix } from '$active-uix';
import { pipe, string, cssValue, type CssUnit } from '$sium';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Direction = 'ltr' | 'rtl';
type ValidateMode = 'built-in' | 'fn-px-only' | 'sium-px-rem';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const uix = getActiveUix();
let tab = $state<Tab>('live');
let value = $state<string | undefined>('16px');
let allowNegative = $state(true);
let validateMode = $state<ValidateMode>('built-in');
let min = $state<number | undefined>(undefined);
let max = $state<number | undefined>(undefined);
let step = $state<number | undefined>(undefined);
let largeStep = $state<number | undefined>(undefined);
let allowMouseWheel = $state(false);
let clampOnBlur = $state(true);
let disabled = $state(false);
let readonly = $state(false);
let required = $state(false);
let invalid = $state(false);
let dir = $state<Direction>('ltr');
let size = $state<CssFieldSize>('md');
let variant = $state<CssFieldVariant>('surface');
let color = $state<CssFieldColor>('primary');
let lastInvalid = $state<string | null>(null);
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Unit whitelist (the `allowedUnits` prop) ─────────────────────────
const UNIT_CHOICES: CssUnit[] = ['px', 'rem', 'em', '%', 'vh', 'vw', 'ch', 'fr', 'deg', 's', 'ms'];
let selectedUnits = $state<CssUnit[]>(['px', 'rem', '%']);
const allowedUnits = $derived(selectedUnits.length ? selectedUnits : undefined);
function toggleUnit(u: CssUnit) {
selectedUnits = selectedUnits.includes(u)
? selectedUnits.filter((x) => x !== u)
: [...selectedUnits, u];
}
// ── Keywords ──────────────────────────────────────────────────────────
const KEYWORD_CHOICES = ['auto', 'inherit', 'initial', 'none', 'fit-content', 'max-content'];
let selectedKeywords = $state<string[]>([]);
const keywords = $derived(selectedKeywords);
function toggleKeyword(k: string) {
selectedKeywords = selectedKeywords.includes(k)
? selectedKeywords.filter((x) => x !== k)
: [...selectedKeywords, k];
}
// ── Validation strategy (built-in functions OR delegate to sium) ──────
const validate = $derived.by<CssFieldValidate | undefined>(() => {
if (validateMode === 'fn-px-only') {
return (v: string) => v.endsWith('px') || 'Only px units allowed';
}
if (validateMode === 'sium-px-rem') {
return pipe(string(), cssValue({ units: ['px', 'rem'], allowNegative }));
}
return undefined;
});
// ── Presets ───────────────────────────────────────────────────────────
type Preset = {
label: string;
units: CssUnit[];
keywords?: string[];
value: string;
step?: number;
};
const PRESETS: Preset[] = [
{ label: 'padding · px/rem', units: ['px', 'rem'], value: '12px', step: 1 },
{ label: 'width · %/px + auto', units: ['%', 'px'], keywords: ['auto'], value: '50%', step: 1 },
{ label: 'font-size · rem/em/px', units: ['rem', 'em', 'px'], value: '1.5rem', step: 0.1 },
{ label: 'duration · s/ms', units: ['s', 'ms'], value: '300ms', step: 50 },
{ label: 'angle · deg', units: ['deg'], value: '45deg', step: 15 },
{ label: 'grid · fr', units: ['fr'], value: '1fr', step: 1 }
];
function applyPreset(p: Preset) {
selectedUnits = [...p.units];
selectedKeywords = [...(p.keywords ?? [])];
value = p.value;
step = p.step;
validateMode = 'built-in';
lastInvalid = null;
}
const compiled = compileMorfo(cssFieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const displayValue = $derived(value === undefined ? 'empty' : value);
const variants: CssFieldVariant[] = ['surface', 'outline', 'ghost'];
const colors: CssFieldColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '-',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
function targetSelector(target: string): string {
if (target === 'scrubber') return '[data-css-field-scrubber]';
if (target === 'input') return '[data-css-field-input]';
return '[data-css-field]';
}
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as CssField from '$soma/components/css-field';",
` let value = $state(${value ? `'${value}'` : 'undefined'});`,
'</' + 'script>',
'',
`<CssField.Provider bind:value allowedUnits={${JSON.stringify(selectedUnits)}}>`,
' <CssField.DecrementTrigger>−</CssField.DecrementTrigger>',
' <CssField.Input />',
' <CssField.IncrementTrigger>+</CssField.IncrementTrigger>',
' <CssField.Scrubber>drag</CssField.Scrubber>',
'</CssField.Provider>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { CssField } from '$uix/eidos/components/css-field';",
` let value = $state(${value ? `'${value}'` : 'undefined'});`,
'</' + 'script>',
'',
'<CssField',
' bind:value',
` allowedUnits={${JSON.stringify(selectedUnits)}}`,
selectedKeywords.length > 0 && ` keywords={${JSON.stringify(selectedKeywords)}}`,
validateMode === 'sium-px-rem' && ' validate={pipe(string(), cssValue({ units: [\'px\', \'rem\'] }))}',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
'>',
' <CssField.DecrementTrigger>−</CssField.DecrementTrigger>',
' <CssField.Input />',
' <CssField.IncrementTrigger>+</CssField.IncrementTrigger>',
' <CssField.Scrubber>drag</CssField.Scrubber>',
'</CssField>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · CSS value field</div>
<h1 data-uix-page-title>CssField</h1>
<p data-uix-page-lede>
Spinbutton for CSS dimension values (px, rem, %, …) with a unit whitelist, optional
increment/decrement triggers and a pointer scrubber. Soma parses and validates via the pure
sium functions — or delegates to a sium schema; Eidos adds visual size.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill
><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span
>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{events.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>keyboard</span>9</span>
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>5</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<CssField
bind:value
{allowedUnits}
{keywords}
{allowNegative}
{validate}
{min}
{max}
{step}
{largeStep}
{allowMouseWheel}
{clampOnBlur}
{disabled}
{readonly}
{required}
{invalid}
{dir}
{size}
{variant}
{color}
onInvalid={(v) => (lastInvalid = v)}
onValueCommit={() => (lastInvalid = null)}
>
<CssField.DecrementTrigger>−</CssField.DecrementTrigger>
<CssField.Input aria-label="CSS value" />
<CssField.IncrementTrigger>+</CssField.IncrementTrigger>
<CssField.Scrubber>drag</CssField.Scrubber>
</CssField>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>type a value, increment, press Enter or drag the scrubber to see events</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span>
<span data-uix-stage-trace-event>{entry.event}</span>
· {entry.family}{entry.intent ? ' · ' + entry.intent : ''}
</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>value</span>
<span data-uix-stage-trace-event>{displayValue}</span>
{#if lastInvalid !== null}
<span data-uix-stage-trace-key style="margin-inline-start: var(--uix-space-2);"
>invalid</span
>
<span data-uix-stage-trace-event>{lastInvalid}</span>
{/if}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}
>API <span data-uix-tab-count>18</span></button
>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}
>Recipe</button
>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
<span data-uix-layer-badge="soma">soma</span> owns parse/validate/step model.
<span data-uix-layer-badge="eidos">eidos</span> owns size.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · value model
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label
>allowedUnits <span data-uix-control-hint>unit whitelist (1st = default)</span></span
>
<span data-uix-chips role="group">
{#each UNIT_CHOICES as u (u)}
<button
data-uix-chip
data-active={selectedUnits.includes(u)}
onclick={() => toggleUnit(u)}>{u}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>keywords <span data-uix-control-hint>opt-in bare keywords</span></span
>
<span data-uix-chips role="group">
{#each KEYWORD_CHOICES as k (k)}
<button
data-uix-chip
data-active={selectedKeywords.includes(k)}
onclick={() => toggleKeyword(k)}>{k}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>validate <span data-uix-control-hint>built-in fns or delegate to sium</span></span
>
<span data-uix-chips role="radiogroup">
{#each ['built-in', 'fn-px-only', 'sium-px-rem'] as m (m)}
<button
data-uix-chip
data-active={validateMode === m}
onclick={() => (validateMode = m as ValidateMode)}>{m}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>min</span>
<input
type="number"
value={min ?? ''}
oninput={(e) =>
(min = e.currentTarget.value === '' ? undefined : Number(e.currentTarget.value))}
/>
</label>
<label data-uix-control>
<span data-uix-control-label>max</span>
<input
type="number"
value={max ?? ''}
oninput={(e) =>
(max = e.currentTarget.value === '' ? undefined : Number(e.currentTarget.value))}
/>
</label>
<label data-uix-control>
<span data-uix-control-label>step <span data-uix-control-hint>auto per-unit</span></span>
<input
type="number"
step="0.1"
value={step ?? ''}
oninput={(e) =>
(step = e.currentTarget.value === '' ? undefined : Number(e.currentTarget.value))}
/>
</label>
<label data-uix-control>
<span data-uix-control-label>largeStep</span>
<input
type="number"
value={largeStep ?? ''}
oninput={(e) =>
(largeStep =
e.currentTarget.value === '' ? undefined : Number(e.currentTarget.value))}
/>
</label>
<label data-uix-control>
<span data-uix-control-label>dir</span>
<span data-uix-chips role="radiogroup">
{#each ['ltr', 'rtl'] as d}
<button data-uix-chip data-active={dir === d} onclick={() => (dir = d as Direction)}
>{d}</button
>
{/each}
</span>
</label>
{#each ['allowNegative', 'allowMouseWheel', 'clampOnBlur', 'disabled', 'readonly', 'required', 'invalid'] as flag}
<label data-uix-control>
<span data-uix-control-label>{flag}</span>
<span data-uix-switch>
{#if flag === 'allowNegative'}
<input type="checkbox" bind:checked={allowNegative} />
<span data-uix-switch-label>{allowNegative ? 'on' : 'off'}</span>
{:else if flag === 'allowMouseWheel'}
<input type="checkbox" bind:checked={allowMouseWheel} />
<span data-uix-switch-label>{allowMouseWheel ? 'on' : 'off'}</span>
{:else if flag === 'clampOnBlur'}
<input type="checkbox" bind:checked={clampOnBlur} />
<span data-uix-switch-label>{clampOnBlur ? 'on' : 'off'}</span>
{:else if flag === 'disabled'}
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
{:else if flag === 'readonly'}
<input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>{readonly ? 'on' : 'off'}</span>
{:else if flag === 'required'}
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>{required ? 'on' : 'off'}</span>
{:else}
<input type="checkbox" bind:checked={invalid} />
<span data-uix-switch-label>{invalid ? 'on' : 'off'}</span>
{/if}
</span>
</label>
{/each}
</div>
<div data-uix-subsection-head>Presets · canonical scenarios</div>
<div data-uix-controls>
<div
style="display: flex; flex-wrap: wrap; gap: var(--uix-space-1); padding: var(--uix-space-2) 0;"
>
{#each PRESETS as p (p.label)}
<button data-uix-chip onclick={() => applyPreset(p)}>{p.label}</button>
{/each}
</div>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as s}
<button
data-uix-chip
data-active={size === s}
onclick={() => (size = s as CssFieldSize)}>{s}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as item}
<button data-uix-chip data-active={variant === item} onclick={() => (variant = item)}
>{item}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as item}
<button data-uix-chip data-active={color === item} onclick={() => (color = item)}>
{item}
</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · CSS-value spinbutton</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · size, variant and intent color</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API + reference comparison</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>Feature</th><th>Radix</th><th>Ark</th><th>React Aria</th><th>UIX</th><th>Decision</th>
</tr>
</thead>
<tbody>
<tr><td class="name">Numeric stepper</td><td>NumberField</td><td>NumberInput</td><td>NumberField</td><td>✅</td><td>Steps the numeric component.</td></tr>
<tr><td class="name">Unit-aware value</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td><td>allowedUnits whitelist; unit preserved on step.</td></tr>
<tr><td class="name">Increment/decrement triggers</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>Optional, hold-to-repeat.</td></tr>
<tr><td class="name">Pointer scrubber</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td><td>Optional, RTL-aware.</td></tr>
<tr><td class="name">Pluggable validation</td><td>❌</td><td>partial</td><td>partial</td><td>✅</td><td>Built-in fns or delegate to a sium schema.</td></tr>
<tr><td class="name">Field integration</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>Inherits disabled/readonly/required/invalid.</td></tr>
</tbody>
</table>
</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">value</td><td>soma</td><td class="default">undefined</td><td>Bindable CSS string ("12px").</td></tr>
<tr><td class="name">allowedUnits</td><td>soma</td><td class="default">all units</td><td>Allowed-units whitelist; 1st = default.</td></tr>
<tr><td class="name">keywords</td><td>soma</td><td class="default">[]</td><td>Opt-in bare keywords.</td></tr>
<tr><td class="name">allowNegative</td><td>soma</td><td class="default">true</td><td>Allow a leading minus.</td></tr>
<tr><td class="name">validate</td><td>soma</td><td class="default">built-in</td><td>Predicate fn or sium schema.</td></tr>
<tr><td class="name">min / max</td><td>soma</td><td class="default">—</td><td>Numeric-component bounds.</td></tr>
<tr><td class="name">step / largeStep</td><td>soma</td><td class="default">per-unit / step*10</td><td>Stepping amounts.</td></tr>
<tr><td class="name">size</td><td>eidos</td><td class="default">md</td><td>xs | sm | md | lg | xl.</td></tr>
<tr><td class="name">variant</td><td>eidos</td><td class="default">surface</td><td>surface | outline | ghost.</td></tr>
<tr><td class="name">color</td><td>eidos</td><td class="default">primary</td><td>Accent palette.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead
><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr
></thead
>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
{#each cssFieldMorfo.parts[1].keyboard ?? [] as key}
<tr><td class="name">{key.key}</td><td>{key.action}</td></tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events + perceptual signature
</h2>
<p data-uix-section-desc>
Value commits resolve through <code>commit-set</code>. The scrubber emits pickup + drag
cues; an out-of-whitelist unit fires <code>signal-warn-invalid</code> (untilFix).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead
><tr
><th>Name</th><th>Family</th><th>Verb</th><th>Intent</th><th>Sequence</th><th>Target</th
><th>Play</th></tr
></thead
>
<tbody>
{#each events as action}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.intent ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td>{action.target}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = stageRef?.querySelector(
targetSelector(action.target)
) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
intent: action.semantic.intent as
| 'neutral'
| 'affirm'
| 'fulfill'
| 'risk'
| 'threat'
| 'loss'
| undefined,
target
});
}}>▶ play</button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors live in <code>src/uix/eidos/components/css-field/css-field.css</code> and consume
Soma focus/scrubbing/invalid/disabled attrs plus Eidos <code>data-size</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-css-field]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Root spinbutton composition shell.</td>
</tr>
<tr>
<td class="name"><code>[data-css-field][data-size][data-variant][data-color]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Visual scale, input treatment and intent palette.</td>
</tr>
<tr>
<td class="name"><code>[data-css-field][data-focused], [data-css-field][data-invalid]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Focus ring + invalid border surfaced by the headless layer.</td>
</tr>
<tr>
<td class="name"
><code>[data-css-field-increment-trigger]</code> /
<code>[data-css-field-decrement-trigger]</code></td
>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Stepper affordances.</td>
</tr>
<tr>
<td class="name"><code>[data-css-field-scrubber][data-scrubbing]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Drag scrubbing affordance and active state.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Role</td><td>Input uses <code>role="spinbutton"</code>.</td></tr>
<tr><td class="name">Value</td><td>Input exposes <code>aria-valuenow</code> (numeric part) + <code>aria-valuetext</code> (full string).</td></tr>
<tr><td class="name">Keyboard</td><td>Arrows, PageUp/PageDown, Home/End and Enter are declared.</td></tr>
<tr><td class="name">Invalid</td><td><code>aria-invalid</code> + a live <code>signal-warn-invalid</code> cue on a disallowed unit.</td></tr>
<tr><td class="name">Triggers</td><td>Increment/decrement controls get translated labels.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.