feat(layout): Layout Batch 3 — scroll-area, separator, splitter + splitter sema rewire

3 new layout primitives close the air port set (15 / 15 done).
`npm run component:audit`: 91 / 91 PASS, all 3 new PASS with 0 errors.

**scroll-area** — eidos compound wrapper over the existing soma:
`<ScrollArea><ScrollArea.Viewport>{content}</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical"><ScrollArea.Thumb />
</ScrollArea.Scrollbar><ScrollArea.Corner /></ScrollArea>`. Mirrors
Radix Primitives / Bits UI shape. Demo 667 lines.

**separator** — visual primitive (`scope: ['eidos']`) with conditional
ARIA: `role="separator"` + `aria-orientation` when not decorative,
`aria-hidden="true"` when decorative. New morfo
`src/uix/morfo/components/separator.ts`. Demo 645 lines.

**splitter** — eidos compound over existing soma:
`<Splitter orientation="horizontal"><Splitter.Panel>{a}</Splitter.Panel>
<Splitter.ResizeTrigger /><Splitter.Panel>{b}</Splitter.Panel>
</Splitter>`. Demo 824 lines.

**Splitter sema rewire — match Slider's handle/drag/commit shape:**

The morfo previously declared only `commit-resize`. Direct-manipulation
primitives (Slider, Splitter) should share the same event vocabulary so
audio/haptic projections feel coherent across the family. Renamed +
expanded to mirror Slider:

- `handle-pick` (family: handle, verb: pick, sequence: coincident,
  target: resize-trigger) — fires on pointerdown when user grabs the
  trigger.
- `handle-drag` (family: handle, verb: drag, sequence: coincident,
  target: resize-trigger) — fires on each pointermove frame.
- `commit-set` (family: commit, verb: set, intent: neutral,
  sequence: post, target: provider) — fires on pointerup. The runtime
  `commit-set` handler is wired to call the consumer's `onResizeEnd`
  callback, so sema and JS callback land at the same moment (parity
  with Slider's `onValueCommit`).

Soma provider triggers these via `this.runtime.trigger(name)` in
`onpointerdown` / `onpointermove` / `notifyResizeEnd` (the latter
unchanged externally — internally now routes through the runtime
trigger).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent a08a0a9890
commit bd2e40366a

@ -0,0 +1,147 @@
# Eidos ScrollArea
Custom-scrollbar container. Hides the native scrollbar and renders a
custom-painted track + thumb on top of a scrollable viewport. The
headless Soma layer owns the scroll math, the visibility timer
(`type='hover' | 'scroll' | 'auto' | 'always'`) and the thumb
position. Eidos paints chrome only — track, thumb, corner, hover and
drag states.
## Superficie
```svelte
<ScrollArea type="hover" scrollHideDelay={600}>
<ScrollArea.Viewport>
<!-- scrollable content goes here -->
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Scrollbar orientation="horizontal">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
<ScrollArea.Corner />
</ScrollArea>
```
The compound shape mirrors Radix Primitives and Bits UI 1:1: the
consumer composes every part explicitly so a vertical-only ScrollArea
omits the horizontal scrollbar (and the Corner) without prop
gymnastics on the root.
## Baseline
Origin: `air/components/layout/scroll-area` (branch `morfo-runtime`),
which itself wrapped the legacy Terra scroll-area implementation.
Adjustments applied during the port:
- Drop the `air-scroll-area-*` class namespace. Eidos targets
`[data-scroll-area]` / `[data-scroll-area-viewport]` / etc., emitted
by the Soma provider so the recipe never depends on consumer
classes.
- Replace the `--air-scroll-area-*` token namespace with bare
`--scroll-area-*` names per the eidos naming convention. The
identifiers map to canonical UIX primitives:
- `--scroll-area-track-bg` → `color-mix` of `--color-neutral-track`
- `--scroll-area-thumb-bg` → `--color-content-muted`
- `--scroll-area-scrollbar-size` → `--space-2-5`
- `--scroll-area-thumb-min-size` → `--space-3`
- Soma is the source of truth. The eidos wrappers are pure
pass-through with no additional ARIA, no additional data-attrs,
no extra props. The morfo (`src/uix/morfo/components/scroll-area.ts`)
declares the contract — Provider / Viewport / Scrollbar / Thumb /
Corner — and the Soma provider emits the markers; eidos just
styles them.
- Hover and drag states use the morfo's `data-hover` /
`data-dragging` presence flags rather than CSS pseudo-classes,
matching the rest of the UIX runtime.
## Comparativa
| Capability | UIX (eidos) | Radix Primitives | Bits UI | react-aria-components |
| --- | --- | --- | --- | --- |
| Compound API (`Provider`/`Viewport`/`Scrollbar`/`Thumb`/`Corner`) | Yes | Yes | Yes | Partial — single `ScrollArea` + virtual list |
| Scrollbar visibility modes | `hover` / `scroll` / `auto` / `always` (soma) | Same four | Same four | `always` / `auto` |
| Horizontal + vertical scrollbars composed independently | Yes | Yes | Yes | Yes |
| Corner element | Yes | Yes | Yes | No |
| Auto-hide delay configurable | Yes (`scrollHideDelay` on soma) | Yes (`scrollHideDelay`) | Yes | No |
| RTL support | Yes (via soma `dir`) | Yes | Yes | Yes |
| Touch action discipline | `touch-action: none` on bar (recipe) | Same | Same | Same |
| Drag state styling | Yes (`data-dragging`) | Yes (`data-state`) | Yes | n/a |
| Custom thumb min size | Yes (`--scroll-area-thumb-min-size`) | CSS variable | CSS variable | n/a |
## Decisiones
- **Soma is the source of state and ARIA.** Eidos adds no
evaluative props — neither `size` nor `variant`. Scrollbar
visibility timing belongs to soma's `type` / `scrollHideDelay`
pair; the chrome's color and thickness live in CSS tokens.
- **Compound API only.** No flat `<ScrollArea>` shortcut. The
consumer composes Viewport + Scrollbar(s) + Corner explicitly so
a single-axis container doesn't pay for the unused parts. Mirrors
Radix Primitives.
- **`Thumb` always sits inside `Scrollbar`.** The morfo declares it
as an optional child (no compositional gymnastics needed): the
Thumb's position and ARIA are wired through the parent
Scrollbar's provider context, so the consumer can omit the Thumb
to render a track-only scrollbar (rare but legal).
- **No prop on the eidos root.** The headless `type` /
`scrollHideDelay` / `dir` go through pass-through; the demo's
controls bind directly to those soma props.
## Eventos Sema
ScrollArea declares 0 events. Scrolling is continuous and
non-evaluative — there is no commit, no emerge, no contact. Soma
emits the `data-state="visible" | "hidden"` markers as the
scrollbar visibility transitions; if a consumer needs a sema
signature on visibility change, it composes ScrollArea inside a
larger primitive (e.g. a Drawer or Popover) that owns the relevant
verb.
The Scrollbar drag does not commit a sema event either: scroll
position is a viewport concern, not a value the surrounding form
should react to. Slider / NumberField / Splitter are the right
primitives when a drag commits a value.
## Gaps
| Gap | Disposition | Detail |
| --- | --- | --- |
| `viewport` style escape for inner padding | implementar | Today the consumer applies padding inside the viewport's child. Adding a `padding` prop on Viewport (passed through to soma) would centralise the API. |
| Sema event on scroll-to-edge (`commit-end`) | diferir | Some apps need to know when the viewport hits top / bottom / start / end. Soma can emit `data-at-top` / `data-at-bottom` already; promoting that to a sema event is a v2 decision. |
| `intersectionThreshold` for sticky headers | diferir | Out of scope for the layout-primitive sweep. Belongs to a future `<Sticky>` primitive on top of ScrollArea. |
| Theme variant `subtle` / `strong` for track | descartar | Token overrides cover this already — `var(--scroll-area-track-bg: …)` is enough; a typed `variant` would manufacture an unused surface. |
| Auto-handle hover targeting via padding-box | descartar | Air shipped the `padding` directly on the bar; modern UAs honour the existing approach. |
## Referencias
- Radix Primitives ScrollArea: https://www.radix-ui.com/primitives/docs/components/scroll-area
- Bits UI ScrollArea: https://bits-ui.com/docs/components/scroll-area
- react-aria-components ScrollArea: https://react-spectrum.adobe.com/react-aria/ScrollArea.html
- WAI-ARIA `scrollbar` role: https://www.w3.org/TR/wai-aria-1.2/#scrollbar
## Passive justification
The morfo declares `scope: ['soma']` (the headless layer owns
hover-timer state, drag math and scroll position), but **no
semantic events**. Scrolling itself is continuous and non-evaluative
— the user moves the viewport, the surrounding application reads
the scroll position; there is no commit / emerge / contact verb to
project onto a perceptual channel.
The Scrollbar's `data-state` (`visible` / `hidden`) IS a transition,
but it is a CSS visibility transition driven by the soma timer
rather than a user-authored intent. Promoting it to a sema event
would either:
- fire on every show / hide of the scrollbar (high-frequency
pollution, equivalent to firing on every frame of a drag), or
- require an arbitrary debounce that the consumer would have to
reverse-engineer.
For consumers that need to react to scroll position (sticky
headers, lazy-load), `data-at-top` / `data-at-bottom` /
`data-at-left` / `data-at-right` on the Viewport are sufficient and
do not require a sema layer.

@ -0,0 +1,44 @@
// ScrollArea — eidos compound API, disciplined option C.
//
// import { ScrollArea } from '$uix/eidos/components/scroll-area';
//
// <ScrollArea type="hover">
// <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
// <ScrollArea.Scrollbar orientation="vertical">
// <ScrollArea.Thumb />
// </ScrollArea.Scrollbar>
// <ScrollArea.Scrollbar orientation="horizontal">
// <ScrollArea.Thumb />
// </ScrollArea.Scrollbar>
// <ScrollArea.Corner />
// </ScrollArea>
import ScrollAreaComponent from './scroll-area.svelte';
import Viewport from './scroll-area-viewport.svelte';
import Scrollbar from './scroll-area-scrollbar.svelte';
import Thumb from './scroll-area-thumb.svelte';
import Corner from './scroll-area-corner.svelte';
type ScrollAreaNamespace = typeof ScrollAreaComponent & {
Viewport: typeof Viewport;
Scrollbar: typeof Scrollbar;
Thumb: typeof Thumb;
Corner: typeof Corner;
};
const ScrollArea = ScrollAreaComponent as ScrollAreaNamespace;
ScrollArea.Viewport = Viewport;
ScrollArea.Scrollbar = Scrollbar;
ScrollArea.Thumb = Thumb;
ScrollArea.Corner = Corner;
export { ScrollArea };
export default ScrollArea;
export type {
ScrollAreaProps,
ScrollAreaViewportProps as ViewportProps,
ScrollAreaScrollbarProps as ScrollbarProps,
ScrollAreaThumbProps as ThumbProps,
ScrollAreaCornerProps as CornerProps,
ScrollAreaType
} from './types';

@ -0,0 +1,10 @@
<script lang="ts">
import * as ScrollArea from '$soma/components/scroll-area';
import type { ScrollAreaCornerProps } from './types';
let { children, ...rest }: ScrollAreaCornerProps = $props();
</script>
<ScrollArea.Corner {...rest}>
{@render children?.()}
</ScrollArea.Corner>

@ -0,0 +1,10 @@
<script lang="ts">
import * as ScrollArea from '$soma/components/scroll-area';
import type { ScrollAreaScrollbarProps } from './types';
let { children, ...rest }: ScrollAreaScrollbarProps = $props();
</script>
<ScrollArea.Scrollbar {...rest}>
{@render children?.()}
</ScrollArea.Scrollbar>

@ -0,0 +1,10 @@
<script lang="ts">
import * as ScrollArea from '$soma/components/scroll-area';
import type { ScrollAreaThumbProps } from './types';
let { children, ...rest }: ScrollAreaThumbProps = $props();
</script>
<ScrollArea.Thumb {...rest}>
{@render children?.()}
</ScrollArea.Thumb>

@ -0,0 +1,13 @@
<script lang="ts">
import * as ScrollArea from '$soma/components/scroll-area';
import type { ScrollAreaViewportProps } from './types';
// Destructure explicitly so the `children` snippet propagates to the
// headless component as a normal prop. Spreading `$props()` directly
// via `{...props}` can lose snippet bindings in Svelte 5.
let { children, ...rest }: ScrollAreaViewportProps = $props();
</script>
<ScrollArea.Viewport {...rest}>
{@render children?.()}
</ScrollArea.Viewport>

@ -0,0 +1,100 @@
/*
* ScrollArea recipe — visual treatment for a custom-scrollbar
* container. The headless Soma layer owns the scroll math, the
* visibility timer (`type='hover' | 'scroll' | 'auto' | 'always'`)
* and the thumb position; Eidos paints the chrome.
*
* Architecture mirrors Radix Primitives / Bits UI:
*
* [data-scroll-area] → outer relative-positioned shell
* [data-scroll-area-viewport] → scroll container (overflow: auto)
* [data-scroll-area-scrollbar] → track (vertical / horizontal)
* [data-scroll-area-thumb] → draggable position indicator
* [data-scroll-area-corner] → square where both bars meet
*
* Tokens stay close to the canonical UIX scale: track and thumb
* borrow from the neutral palette; the corner tints with the same
* track color so a dual-axis container reads as a single unit.
*/
[data-scroll-area] {
position: relative;
overflow: hidden;
border-radius: var(--scroll-area-radius, var(--radius-md));
background: var(--scroll-area-bg, transparent);
}
[data-scroll-area-viewport] {
inline-size: 100%;
block-size: 100%;
}
/* The viewport scrolls; expose the scroll position to the thumb via
the headless layer's data-attrs without bleeding into Eidos. */
[data-scroll-area-scrollbar] {
display: flex;
user-select: none;
touch-action: none;
padding: var(--scroll-area-scrollbar-padding, var(--space-0-5));
background: var(--scroll-area-track-bg, color-mix(in srgb, var(--color-neutral-track) 60%, transparent));
transition:
background var(--scroll-area-transition-duration, var(--duration-fast))
var(--scroll-area-transition-ease, var(--ease-default)),
opacity var(--scroll-area-transition-duration, var(--duration-fast))
var(--scroll-area-transition-ease, var(--ease-default));
}
[data-scroll-area-scrollbar][data-orientation='vertical'] {
inline-size: var(--scroll-area-scrollbar-size, var(--space-2-5));
block-size: 100%;
flex-direction: column;
}
[data-scroll-area-scrollbar][data-orientation='horizontal'] {
block-size: var(--scroll-area-scrollbar-size, var(--space-2-5));
inline-size: 100%;
flex-direction: row;
}
[data-scroll-area-scrollbar][data-state='hidden'] {
opacity: 0;
pointer-events: none;
}
[data-scroll-area-scrollbar][data-state='visible'] {
opacity: 1;
}
[data-scroll-area-scrollbar][data-hover],
[data-scroll-area-scrollbar][data-dragging] {
background: var(--scroll-area-track-bg-hover, color-mix(in srgb, var(--color-neutral-track) 84%, transparent));
}
[data-scroll-area-scrollbar]:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
[data-scroll-area-thumb] {
flex: 1;
border-radius: var(--scroll-area-thumb-radius, var(--radius-full));
background: var(--scroll-area-thumb-bg, var(--color-content-muted));
transition: background var(--scroll-area-transition-duration, var(--duration-fast))
var(--scroll-area-transition-ease, var(--ease-default));
min-inline-size: var(--scroll-area-thumb-min-size, var(--space-3));
min-block-size: var(--scroll-area-thumb-min-size, var(--space-3));
}
[data-scroll-area-thumb]:hover,
[data-scroll-area-scrollbar][data-dragging] [data-scroll-area-thumb] {
background: var(--scroll-area-thumb-bg-hover, var(--color-content-secondary));
}
[data-scroll-area-corner] {
background: var(--scroll-area-track-bg, color-mix(in srgb, var(--color-neutral-track) 60%, transparent));
}
[data-scroll-area-corner][data-state='hidden'] {
opacity: 0;
}

@ -0,0 +1,23 @@
<script lang="ts">
/**
* Eidos `<ScrollArea>` — root component. Wraps Soma's
* `<ScrollArea.Provider>` to set up the headless context; children
* compose via the named members attached in `index.ts`:
*
* <ScrollArea type="hover">
* <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
* <ScrollArea.Scrollbar orientation="vertical">
* <ScrollArea.Thumb />
* </ScrollArea.Scrollbar>
* <ScrollArea.Corner />
* </ScrollArea>
*/
import * as ScrollArea from '$soma/components/scroll-area';
import type { ScrollAreaProps } from './types';
let { children, ...rest }: ScrollAreaProps = $props();
</script>
<ScrollArea.Provider {...rest}>
{@render children?.()}
</ScrollArea.Provider>

@ -0,0 +1,45 @@
import type {
ProviderProps,
ViewportProps,
ScrollbarProps,
ThumbProps,
CornerProps,
ScrollAreaType
} from '$soma/components/scroll-area';
/**
* Eidos ScrollArea types.
*
* Compound shape mirrors Radix Primitives:
*
* <ScrollArea>
* <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
* <ScrollArea.Scrollbar orientation="vertical">
* <ScrollArea.Thumb />
* </ScrollArea.Scrollbar>
* <ScrollArea.Scrollbar orientation="horizontal">
* <ScrollArea.Thumb />
* </ScrollArea.Scrollbar>
* <ScrollArea.Corner />
* </ScrollArea>
*
* Eidos adds no evaluative props — geometry and scrollbar timing live
* fully in the Soma headless layer. The recipe consumes the
* `data-state` / `data-orientation` markers emitted by the morfo
* runtime so visibility transitions and orientation swaps require no
* additional wiring from Eidos.
*/
/** Props for the `<ScrollArea>` root. Pass-through from headless. */
export type ScrollAreaProps = ProviderProps;
/** Props for `<ScrollArea.Viewport>`. Pass-through from headless. */
export type ScrollAreaViewportProps = ViewportProps;
/** Props for `<ScrollArea.Scrollbar>`. Pass-through from headless. */
export type ScrollAreaScrollbarProps = ScrollbarProps;
/** Props for `<ScrollArea.Thumb>`. Pass-through from headless. */
export type ScrollAreaThumbProps = ThumbProps;
/** Props for `<ScrollArea.Corner>`. Pass-through from headless. */
export type ScrollAreaCornerProps = CornerProps;
/** Scrollbar visibility behavior — re-exported from Soma for ergonomic imports. */
export type { ScrollAreaType };

@ -0,0 +1,124 @@
# Eidos Separator
Visual divider between sections. A single `<div>` styled by the
recipe based on its `data-orientation` attribute. The component
toggles between a semantic separator (with `role="separator"` and
`aria-orientation`) and a decorative one (`aria-hidden="true"`,
no role) via the `decorative` prop.
## Superficie
```svelte
<!-- Default: horizontal, semantically a separator. -->
<Separator />
<!-- Vertical, e.g. between toolbar groups. -->
<Separator orientation="vertical" />
<!-- Decorative: hidden from assistive tech (visual chrome only). -->
<Separator decorative />
<!-- Responsive: horizontal on mobile, vertical from md upward. -->
<Separator orientation={{ base: 'horizontal', md: 'vertical' }} />
```
## Baseline
Origin: `air/components/layout/separator` (branch `morfo-runtime`).
Adjustments applied during the port:
- Drop the `air-separator` class. Eidos targets `[data-separator]`
via the marker emitted by the wrapper, so the recipe stays
framework-agnostic and consumers do not need to opt in by adding
a class.
- Replace the `--air-separator-*` token namespace with bare
`--separator-*` names (UIX naming convention from
`eidos/components/README.md`).
- Logical sizing via `inline-size` / `block-size` instead of
`width` / `height` so the rule mirrors automatically in RTL
contexts and inside vertical writing modes.
- `min-block-size` on vertical separators is now `var(--space-4)`
(was `var(--air-separator-min-length)`, which mapped to the same
scale via a token shim). The fallback in the recipe keeps the
override surface stable.
- Eidos-native: no soma backing, no morfo events. The morfo's
scope is `['eidos']` and the conditional ARIA contract
(`role`/`aria-orientation` when `!decorative`,
`aria-hidden="true"` when `decorative`) is encoded
declaratively.
## Comparativa
| Capability | UIX (eidos) | Radix Primitives | Mantine | Chakra UI |
| --- | --- | --- | --- | --- |
| `orientation` prop | Yes (`horizontal` / `vertical`, responsive) | Yes (string) | Yes (`horizontal` / `vertical`) | Yes (`horizontal` / `vertical`) |
| Responsive `orientation` | Yes (`ResponsiveProp`) | No | Limited (per-breakpoint via Sx) | Yes via `_breakpoints` |
| `decorative` prop | Yes — drops role + adds `aria-hidden` | Yes (identical semantics) | No — separator is always semantic | Implicit (no role if `aria-hidden` set manually) |
| `role="separator"` when semantic | Yes | Yes | Yes | Yes |
| `aria-orientation` when semantic | Yes | Yes | No (relies on visible orientation) | No |
| Logical sizing (`inline-size`) | Yes | No (physical sides) | No | No |
| Bare element (no soma) | Yes — pure CSS recipe | Yes | Yes | Yes |
| Token-based color / thickness | Yes (`--separator-color`, `--separator-thickness`) | Themed via CSS variables | Theme prop | Theme prop |
## Decisiones
- **Single Provider part, no compound API.** A separator is a single
element; there are no slots, no inner sub-parts, no children. The
morfo declares one part (`Provider`) emitting the
`data-separator` marker so the recipe and audit tools can find
it.
- **Conditional ARIA via `condition`.** The morfo's `aria` entries
use `condition: { when: 'prop-falsy', prop: 'decorative' }` and
`condition: { when: 'prop-truthy', prop: 'decorative' }` instead
of two parts. This keeps the contract honest — the same DOM
element flips its accessibility shape based on the consumer
prop.
- **`aria-orientation` is `recommended`, not `required`.** Some
assistive-tech implementations ignore the attribute on
`role="separator"` and infer orientation from visual layout. The
audit script does not flag missing values when severity is
`recommended`.
- **Eidos-native, no soma backing.** No state, no callbacks, no
ARIA wiring beyond the conditional role. Adding a soma layer
would manufacture indirection (rule 4.6: no re-export facades).
- **Logical CSS axes.** The recipe uses `inline-size` /
`block-size` so the divider mirrors automatically in RTL
documents and inside vertical writing modes — consistent with
Box / Flex / Stack.
## Eventos Sema
Separator declares 0 events. As a passive layout primitive it does
not commit, emerge, or react to anything — it just paints a rule.
Components that animate sections appearing or disappearing should
compose Separator inside a primitive (Collapsible, Drawer, Dialog)
that owns the relevant sema verbs.
## Gaps
| Gap | Disposition | Detail |
| --- | --- | --- |
| `thickness` prop (replace token override) | diferir | Today consumers set `--separator-thickness` via style or class. Reconsider if ≥2 real consumers need a strongly-typed prop. |
| `inset` / start-end padding | diferir | Mantine ships `Divider inset` for indented separators inside menus. Bottom priority — composable via `marginInlineStart` on a Box wrapper. |
| `label` slot (text in the middle of the rule) | diferir | Mantine `Divider label="Or"` pattern. Will need its own part declaration in the morfo and a recipe layer. Not in scope for the layout-primitive sweep. |
| `variant="dashed"` / `"dotted"` | descartar | Tokenized via CSS `border-style` override is cleaner than a prop. Stays in user space. |
| Decorative + horizontal default min-length | descartar | The minimum length only matters on the vertical axis where the cross-axis collapses to content. Horizontal already fills `100%`. |
## Referencias
- Radix Primitives Separator: https://www.radix-ui.com/primitives/docs/components/separator
- Mantine Divider: https://mantine.dev/core/divider/
- Chakra UI Divider: https://chakra-ui.com/docs/components/divider
- WAI-ARIA `separator` role: https://www.w3.org/TR/wai-aria-1.2/#separator
## Passive justification
Visual-only primitive (`scope: ['eidos']` in the morfo). A single
Provider part emits the `[data-separator]` + `[data-orientation]`
markers. The conditional ARIA contract toggles `role` /
`aria-orientation` / `aria-hidden` from the `decorative` prop, but
the element itself never holds state, never emits a sema event,
never participates in a keyboard contract. The morfo is therefore
deliberately events-free and the demo's Sema tab states the
absence explicitly.

@ -0,0 +1,12 @@
// Separator — visual divider between sections.
//
// import { Separator } from '$uix/eidos/components/separator';
//
// <Separator />
// <Separator orientation="vertical" />
// <Separator decorative />
import Separator from './separator.svelte';
export { Separator };
export default Separator;
export type { SeparatorProps, SeparatorOrientation } from './types';

@ -0,0 +1,35 @@
/*
* Separator recipe — purely geometric. A horizontal separator fills
* the inline axis with a 1px-thick rule against the muted border
* color; a vertical separator fills the block axis with the same
* thickness. The tokens collapse to UIX primitives:
*
* --separator-thickness → border-width (1px equivalent in the
* base theme)
* --separator-color → muted border color, identical to the
* tone of Field / Card borders so the
* divider blends with surrounding chrome.
*
* The recipe uses logical sizing (`inline-size` / `block-size`) so
* RTL contexts flip automatically. The shrink-to-fit defaults
* (`flex-shrink: 0` + `min-block-size`) keep the rule visible inside
* Flex / Stack parents where the cross axis may collapse to content.
*/
[data-separator] {
flex-shrink: 0;
border: 0;
background: var(--separator-color, var(--color-border-default));
}
[data-separator][data-orientation='horizontal'] {
inline-size: 100%;
block-size: var(--separator-thickness, var(--border-width));
}
[data-separator][data-orientation='vertical'] {
inline-size: var(--separator-thickness, var(--border-width));
block-size: 100%;
min-block-size: var(--separator-min-length, var(--space-4));
align-self: stretch;
}

@ -0,0 +1,45 @@
<script lang="ts">
/**
* Eidos `<Separator>` — visual divider between sections. Single
* `<div>` element; the recipe sizes it based on `data-orientation`.
* Two modes:
*
* - semantic (default): emits `role="separator"` +
* `aria-orientation`, exposing the divider to assistive tech.
* - decorative (`decorative` prop): drops the role and adds
* `aria-hidden="true"`.
*
* <Separator />
* <Separator orientation="vertical" />
* <Separator decorative />
*
* The `orientation` prop is responsive; it is resolved through
* `ActiveEidos.resolve()` so a `{ base: 'horizontal', md:
* 'vertical' }` value collapses to the active breakpoint at
* runtime.
*/
import { ActiveEidos } from '$uix/eidos';
import type { SeparatorOrientation, SeparatorProps } from './types';
let {
orientation = 'horizontal',
decorative = false,
class: className,
...restProps
}: SeparatorProps = $props();
const eidos = ActiveEidos.require();
const resolvedOrientation = $derived<SeparatorOrientation>(
eidos.resolve(orientation) ?? 'horizontal'
);
</script>
<div
{...restProps}
class={className}
data-separator=""
data-orientation={resolvedOrientation}
role={decorative ? undefined : 'separator'}
aria-orientation={decorative ? undefined : resolvedOrientation}
aria-hidden={decorative ? 'true' : undefined}
></div>

@ -0,0 +1,37 @@
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/**
* Orientation of the separator. `horizontal` lays the divider across
* the inline axis (default), `vertical` across the block axis. The
* value is responsive: the consumer may pass `'horizontal'`,
* `'vertical'`, or a responsive object `{ base: 'horizontal', md:
* 'vertical' }`.
*/
export type SeparatorOrientation = 'horizontal' | 'vertical';
/**
* Public props for the `<Separator>` primitive.
*
* The separator is single-element: a `<div>` that styles itself based
* on its `data-orientation` attribute. When `decorative` is true the
* element drops its ARIA role and is hidden from assistive
* technologies; otherwise it advertises `role="separator"` and
* `aria-orientation`.
*/
export type SeparatorProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/**
* Layout direction of the divider. `horizontal` stretches across
* the inline axis; `vertical` along the block axis. Responsive
* forms are accepted via `ResponsiveProp`.
* @default 'horizontal'
*/
orientation?: ResponsiveProp<SeparatorOrientation>;
/**
* Drop the ARIA `separator` role and mark the element as
* `aria-hidden`. Use when the divider is purely visual (e.g.
* decorating a card border) and no logical sectioning exists.
* @default false
*/
decorative?: boolean;
};

@ -0,0 +1,141 @@
# Eidos Splitter
Two-or-more resizable panels separated by draggable handles. The
headless Soma layer owns the resize math (panel sizes as
percentages, min / max clamping, collapsed state, keyboard arrow
steps); the morfo declares a single semantic event,
`commit-resize`, fired on drag-end (mouse-up). Eidos paints chrome:
panels, the handle, its hover / drag affordance, and the center
grip.
## Superficie
```svelte
<Splitter orientation="horizontal">
<Splitter.Panel panelId="a" defaultSize={50} minSize={20}>
Left panel
</Splitter.Panel>
<Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
<Splitter.Panel panelId="b" defaultSize={50} minSize={20}>
Right panel
</Splitter.Panel>
</Splitter>
<!-- Vertical with collapsible top panel -->
<Splitter orientation="vertical">
<Splitter.Panel panelId="header" defaultSize={30} collapsible collapsedSize={0}>
Header
</Splitter.Panel>
<Splitter.ResizeTrigger panelBefore="header" panelAfter="body" />
<Splitter.Panel panelId="body" defaultSize={70}>
Body
</Splitter.Panel>
</Splitter>
```
## Baseline
Origin: `air/components/layout/splitter` (branch `morfo-runtime`),
which wrapped the legacy Terra splitter implementation.
Adjustments applied during the port:
- Drop the `air-splitter-*` class namespace. Eidos targets
`[data-splitter]` / `[data-splitter-panel]` /
`[data-splitter-resize-trigger]` via the markers emitted by the
morfo runtime.
- Replace the `--air-splitter-*` token namespace with bare
`--splitter-*` names per the eidos naming convention. The
identifiers map to canonical UIX primitives:
- `--splitter-bg` → `--color-surface-default`
- `--splitter-border-color` → `--color-border-default`
- `--splitter-handle-bg` → `--color-border-subtle`
- `--splitter-handle-bg-active` → `--color-primary-solid`
- `--splitter-handle-size` → `--space-2`
- `--splitter-grip-bg` → `--color-content-muted`
- Wire the `commit-resize` sema event in the recipe: when the
morfo's signal hold stamps `data-event='commit-resize'` on the
resize trigger, the handle paints in the active color until the
hold expires. Air had no equivalent because air predates the sema
visual channel.
- Soma is the source of state. The eidos wrappers are pure
pass-through; min / max / collapsible / collapsedSize / keyboard
step all live on `<Splitter.Panel>` props (PanelProps) or
`<Splitter>` props (`disabled`, `orientation`, `keyboardStep`).
## Comparativa
| Capability | UIX (eidos) | Radix Themes Splitter | Ark UI Splitter | react-resizable-panels |
| --- | --- | --- | --- | --- |
| Compound API (Panel / ResizeTrigger) | Yes | Yes | Yes | Yes |
| `orientation` prop (horizontal / vertical) | Yes (responsive via soma `dir`) | Yes | Yes | Yes |
| `defaultSize` per panel (%) | Yes | Yes | Yes | Yes |
| `minSize` / `maxSize` per panel | Yes | Yes | Yes | Yes |
| `collapsible` + `collapsedSize` | Yes | No | Yes | Yes |
| `onResize` callback (live during drag) | Yes (`onResize`) | No (only commit) | Yes | Yes |
| `onResizeEnd` callback (drag commit) | Yes — also raised as sema `commit-resize` | Yes | Yes | Yes |
| Keyboard arrow resize | Yes (configurable step via `keyboardStep`) | Yes | Yes | Yes |
| Home / End jump to min / max | Yes | Yes | Yes | No |
| Enter to toggle collapse | Yes | No | Yes | No |
| Sema event surface | Yes (`commit-resize` in `commit` family) | No | No | No |
| Drag affordance (center grip) | Yes (eidos pseudo) | Yes | Yes | n/a (consumer styles) |
## Decisiones
- **Soma is the source of state and ARIA.** Eidos adds no
evaluative props — no `size`, no `variant`, no `intent`. Panel
sizing belongs to the morfo's `propRef('size')` mapping, the
active drag belongs to `data-dragging`, keyboard belongs to the
morfo's keyboard table.
- **Single sema event: `commit-resize`.** Live drag (every pixel
movement) is NOT a sema event — it would saturate the perceptual
channels with high-frequency noise. Only the drag commit (mouse
up, key release after arrow resize) fires the sema verb. This
matches the `feedback_per_event_intent_intrinsic` principle:
per-event intent reflects the act's own evaluative load — a
resize commit is `commit.set` with `neutral` intent (the user
set a new value; the result is geometric, neither affirm nor
threat).
- **Eidos pulses the handle on `commit-resize`.** The recipe
selector
`[data-splitter-resize-trigger][data-event='commit-resize']`
paints the handle in the active color during the signal hold,
giving the user visual confirmation that the drag was committed.
The pulse fades automatically when the hold expires.
- **`Indicator` is not implemented as a separate part.** The
morfo lists `Provider` / `Panel` / `ResizeTrigger`; the brief
mentioned an optional `Indicator` part for the grip / handle
visualisation, but the grip is owned by the recipe's `::before`
pseudo — promoting it to a part would manufacture a DOM element
the consumer never needs to override. The decision is documented
here so a future port can revisit.
## Eventos Sema
| Name | Family | Verb | Sequence | Intent | Target |
| --- | --- | --- | --- | --- | --- |
| `commit-resize` | `commit` | `set` | `post` | `neutral` | `resize-trigger` |
The event fires on drag-end (mouse-up) and on keyboard arrow-key
release. The signal hold is short (`brief` band from
`SEMA_DURATIONS`) so the visual pulse on the handle disappears
quickly. Sound and haptic channels project a low-intensity feedback
suitable for a layout adjustment.
## Gaps
| Gap | Disposition | Detail |
| --- | --- | --- |
| `Splitter.Indicator` as a separately stylable part | diferir | The grip is rendered by `::before` today. Promote when ≥2 consumers want to override the grip independently (e.g. custom icon). |
| Sema event on `panel-collapse` / `panel-expand` | diferir | When a Panel toggles its `collapsed` state via Enter, an `emerge` family event would be natural. Out of scope for the layout-primitive sweep. |
| `nested` mode for splitter-in-splitter ergonomics | diferir | Today the consumer manually nests `<Splitter>`s. A `nested` flag could hide the outer border to avoid double-borders. |
| 3+ panel keyboard parity check (Tab through handles) | implementar | Manual test pending — the soma test covers two-panel only; need a multi-panel keyboard walk. |
| `variant="ghost"` for borderless splitter | descartar | Token override (`--splitter-border-color: transparent;`) is enough. Variant would manufacture a typed surface. |
## Referencias
- Radix Themes Splitter: https://www.radix-ui.com/themes/docs/components/splitter
- Ark UI Splitter: https://ark-ui.com/docs/components/splitter
- react-resizable-panels: https://github.com/bvaughn/react-resizable-panels
- allotment: https://github.com/johnwalley/allotment
- WAI-ARIA `separator` role with `aria-valuenow`: https://www.w3.org/TR/wai-aria-1.2/#separator

@ -0,0 +1,30 @@
// Splitter — eidos compound API, disciplined option C.
//
// import { Splitter } from '$uix/eidos/components/splitter';
//
// <Splitter orientation="horizontal">
// <Splitter.Panel panelId="a" defaultSize={50}>{left}</Splitter.Panel>
// <Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
// <Splitter.Panel panelId="b" defaultSize={50}>{right}</Splitter.Panel>
// </Splitter>
import SplitterComponent from './splitter.svelte';
import Panel from './splitter-panel.svelte';
import ResizeTrigger from './splitter-resize-trigger.svelte';
type SplitterNamespace = typeof SplitterComponent & {
Panel: typeof Panel;
ResizeTrigger: typeof ResizeTrigger;
};
const Splitter = SplitterComponent as SplitterNamespace;
Splitter.Panel = Panel;
Splitter.ResizeTrigger = ResizeTrigger;
export { Splitter };
export default Splitter;
export type {
SplitterProps,
SplitterPanelProps as PanelProps,
SplitterResizeTriggerProps as ResizeTriggerProps
} from './types';

@ -0,0 +1,21 @@
<script lang="ts">
import * as Splitter from '$soma/components/splitter';
import type { SplitterPanelProps } from './types';
// Soma's Splitter.Panel exposes `{ collapsed, size }` to its children
// snippet. The eidos wrapper passes the consumer's children through
// untouched so that `<Splitter.Panel>{#snippet children({ collapsed,
// size })}…{/snippet}</Splitter.Panel>` keeps working without the
// consumer knowing about the eidos layer.
//
// Destructure explicitly + rename to `consumerChildren` so the inner
// `#snippet children` declaration doesn't shadow the prop (Svelte 5
// shadow-recursion guard, §12.4 of the demo guide).
let { children: consumerChildren, ...rest }: SplitterPanelProps = $props();
</script>
<Splitter.Panel {...rest}>
{#snippet children(args: { collapsed: boolean; size: number })}
{@render consumerChildren?.(args)}
{/snippet}
</Splitter.Panel>

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

@ -0,0 +1,117 @@
/*
* Splitter recipe — paints two-or-more resizable panels separated by
* draggable handles. Soma owns the resize math (panel sizes, min /
* max clamping, collapsed state, keyboard arrow steps) and the
* `commit-resize` sema event; the recipe consumes the morfo's
* data-attrs to draw orientation-aware geometry plus hover / drag
* affordances on the resize trigger.
*
* [data-splitter] → flex container (row / column)
* [data-splitter-panel] → individual panel (flex item)
* [data-splitter-resize-trigger] → handle between two panels
*
* The handle gets a center grip (the `::before` pseudo) sized to
* match the orientation: tall and narrow in horizontal layouts,
* short and wide in vertical layouts. The grip is the affordance
* that tells the user the handle is draggable, not a passive border.
*/
[data-splitter] {
display: flex;
min-inline-size: 0;
min-block-size: 0;
border: var(--splitter-border-width, var(--border-width)) solid
var(--splitter-border-color, var(--color-border-default));
border-radius: var(--splitter-radius, var(--radius-md));
background: var(--splitter-bg, var(--color-surface-default));
overflow: hidden;
}
[data-splitter][data-orientation='vertical'] {
flex-direction: column;
}
[data-splitter][data-disabled] {
opacity: var(--splitter-disabled-opacity, 0.6);
}
[data-splitter-panel] {
min-inline-size: 0;
min-block-size: 0;
background: var(--splitter-panel-bg, transparent);
overflow: hidden;
}
[data-splitter-panel][data-state='collapsed'] {
pointer-events: none;
}
[data-splitter-resize-trigger] {
position: relative;
flex: 0 0 auto;
background: var(--splitter-handle-bg, var(--color-border-subtle));
transition: background var(--splitter-transition-duration, var(--duration-fast))
var(--splitter-transition-ease, var(--ease-default));
}
[data-splitter-resize-trigger][data-orientation='horizontal'] {
inline-size: var(--splitter-handle-size, var(--space-2));
cursor: col-resize;
}
[data-splitter-resize-trigger][data-orientation='vertical'] {
block-size: var(--splitter-handle-size, var(--space-2));
cursor: row-resize;
}
[data-splitter-resize-trigger]:hover,
[data-splitter-resize-trigger][data-state='hover'] {
background: var(--splitter-handle-bg-hover, var(--color-border-default));
}
[data-splitter-resize-trigger]:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
[data-splitter-resize-trigger][data-state='dragging'],
[data-splitter-resize-trigger][data-dragging] {
background: var(--splitter-handle-bg-active, var(--color-primary-solid));
}
[data-splitter-resize-trigger][data-disabled] {
cursor: default;
background: var(--splitter-handle-bg, var(--color-border-subtle));
}
/* Center grip — a small pill in the middle of the handle. Sized to
match orientation: tall for horizontal handles (so the user can
target it on a thin column), wide for vertical handles. */
[data-splitter-resize-trigger]::before {
content: '';
position: absolute;
inset: 50%;
border-radius: var(--splitter-grip-radius, var(--radius-full));
background: var(--splitter-grip-bg, var(--color-content-muted));
transform: translate(-50%, -50%);
}
[data-splitter-resize-trigger][data-orientation='horizontal']::before {
inline-size: var(--splitter-grip-cross, 2px);
block-size: var(--splitter-grip-main, var(--space-4));
}
[data-splitter-resize-trigger][data-orientation='vertical']::before {
inline-size: var(--splitter-grip-main, var(--space-4));
block-size: var(--splitter-grip-cross, 2px);
}
/* Sema event hook — when commit-resize fires, the eidos events layer
stamps `data-event="commit-resize"` for `signal hold` duration.
Pulse the handle so the user gets visual confirmation that the
drag was committed. */
[data-splitter-resize-trigger][data-event='commit-resize'][data-event-phase='active'] {
background: var(--splitter-handle-bg-active, var(--color-primary-solid));
}

@ -0,0 +1,21 @@
<script lang="ts">
/**
* Eidos `<Splitter>` — root component. Wraps Soma's
* `<Splitter.Provider>` to set up the headless context; children
* compose via the named members attached in `index.ts`:
*
* <Splitter orientation="horizontal">
* <Splitter.Panel panelId="a" defaultSize={50}>{left}</Splitter.Panel>
* <Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
* <Splitter.Panel panelId="b" defaultSize={50}>{right}</Splitter.Panel>
* </Splitter>
*/
import * as Splitter from '$soma/components/splitter';
import type { SplitterProps } from './types';
let { children, ...rest }: SplitterProps = $props();
</script>
<Splitter.Provider {...rest}>
{@render children?.()}
</Splitter.Provider>

@ -0,0 +1,30 @@
import type {
ProviderProps,
PanelProps,
ResizeTriggerProps
} from '$soma/components/splitter';
/**
* Eidos Splitter types.
*
* Compound shape mirrors Radix Themes Splitter / Ark UI Splitter:
*
* <Splitter orientation="horizontal">
* <Splitter.Panel panelId="a" defaultSize={50}>{left}</Splitter.Panel>
* <Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
* <Splitter.Panel panelId="b" defaultSize={50}>{right}</Splitter.Panel>
* </Splitter>
*
* Eidos adds no evaluative props — keyboard, drag math, min / max
* clamping, collapsed state, etc. all live in Soma. The recipe
* consumes the morfo's data-attrs (`data-orientation`, `data-state`,
* `data-dragging`, `data-disabled`) to paint the panels and the
* resize handle.
*/
/** Props for the `<Splitter>` root. Pass-through from headless. */
export type SplitterProps = ProviderProps;
/** Props for `<Splitter.Panel>`. Pass-through from headless. */
export type SplitterPanelProps = PanelProps;
/** Props for `<Splitter.ResizeTrigger>`. Pass-through from headless. */
export type SplitterResizeTriggerProps = ResizeTriggerProps;

@ -73,6 +73,9 @@
@import './components/auto-grid/auto-grid.css';
@import './components/banner/banner.css';
@import './components/float/float.css';
@import './components/scroll-area/scroll-area.css';
@import './components/separator/separator.css';
@import './components/splitter/splitter.css';
@import './components/text/text.css';
@import './components/heading/heading.css';
@import './components/display/display.css';

@ -0,0 +1,62 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Separator — visual divider between sections (layout primitive).
*
* Eidos-native: there is no headless state to coordinate. The morfo
* declares a single Provider part with conditional ARIA contracts so
* the consumer can choose between a semantic separator (default,
* `role="separator"` + `aria-orientation`) and a purely decorative
* one (`aria-hidden="true"`, no role). The `data-orientation` data
* attr is emitted in both cases so the recipe sizes the element
* regardless of accessibility intent.
*
* Justification for 0-event surface: a separator does not commit,
* emerge, or transition. It is geometry plus an optional accessibility
* hint. Adding events would manufacture semantics the primitive
* doesn't carry. Same justification as `box.ts` / `flex.ts` /
* `aspect-ratio.ts`.
*/
export const separatorMorfo = {
name: 'Separator',
kebab: 'separator',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'separator',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
{
attr: 'data-orientation',
values: ['horizontal', 'vertical'],
value: v.propRef('orientation')
}
],
aria: [
{
attr: 'role',
value: v.literal('separator'),
condition: { when: 'prop-falsy', prop: 'decorative' },
severity: 'required'
},
{
attr: 'aria-orientation',
value: v.propRef('orientation'),
condition: { when: 'prop-falsy', prop: 'decorative' },
severity: 'recommended'
},
{
attr: 'aria-hidden',
value: v.literal('true'),
condition: { when: 'prop-truthy', prop: 'decorative' },
severity: 'required'
}
]
}
]
} as const satisfies Morfo;

@ -10,14 +10,40 @@ export const splitterMorfo = {
resize: '#?components.splitter.resize|Resize panels'
},
events: [
// Splitter mirrors Slider's three-event grip/drag/commit shape. Both
// are direct-manipulation primitives where the user grabs a handle,
// drags it, and releases — the sema rendering should match so
// audio/haptic projections feel coherent across the family.
{
// Handle drag committed (mouse-up). Continuous resize during drag
// is not a sema event — only the settled value matters.
name: 'commit-resize',
// User grabs the resize trigger (pointerdown). Coincident with
// the gesture start — no settled value yet.
name: 'handle-pick',
semantic: {
family: 'handle',
verb: 'pick',
target: v.partRef('resize-trigger'),
sequence: 'coincident'
}
},
{
// Continuous resize during drag (pointermove). Coincident with
// each frame of motion — the value is in flux.
name: 'handle-drag',
semantic: {
family: 'handle',
verb: 'drag',
target: v.partRef('resize-trigger'),
sequence: 'coincident'
}
},
{
// Value committed on release (pointerup). The settled panel
// sizes are now the source of truth.
name: 'commit-set',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('resize-trigger'),
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
}

@ -80,6 +80,12 @@ export class SplitterProvider {
orientation: () => this.opts.orientation.current,
disabled: () => this.opts.disabled.current,
resizing: () => this.resizing
},
events: {
// `commit-set` fires the consumer's `onResizeEnd` callback so
// the sema event and the user callback land at the same moment
// (parity with Slider's `commit-set` → `onValueCommit` wiring).
'commit-set': () => this.opts.onResizeEnd.current(this.getSizes())
}
});
this.runtimePart = this.runtime.part('provider', {
@ -183,7 +189,11 @@ export class SplitterProvider {
}
notifyResizeEnd() {
this.opts.onResizeEnd.current(this.getSizes());
// Route through the runtime so the sema `commit-set` event fires
// at the same moment as the consumer's `onResizeEnd` callback.
// The runtime's `commit-set` handler (registered in the
// constructor) calls `onResizeEnd` for us.
void this.runtime.trigger('commit-set');
}
// ── Collapse/Expand ──────────────────────────────────────────────────────
@ -445,6 +455,9 @@ export class SplitterResizeTriggerProvider {
// Capture immediately — resize handle has no child buttons to protect
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Sema: the user grabbed the handle. Coincident with the gesture.
void this.provider.runtime.trigger('handle-pick');
};
readonly onpointermove = (e: PointerEvent) => {
@ -474,6 +487,9 @@ export class SplitterResizeTriggerProvider {
}
this.provider.resizePanels(this.panelBeforeId, this.panelAfterId, deltaPercent);
// Sema: continuous drag. Coincident with each motion frame.
void this.provider.runtime.trigger('handle-drag');
};
readonly onpointerup = (_e: PointerEvent) => {

@ -227,7 +227,10 @@
{ slug: '/uix/components/aspect-ratio', label: 'AspectRatio' },
{ slug: '/uix/components/auto-grid', label: 'AutoGrid' },
{ slug: '/uix/components/banner', label: 'Banner' },
{ slug: '/uix/components/float', label: 'Float' }
{ slug: '/uix/components/float', label: 'Float' },
{ slug: '/uix/components/scroll-area', label: 'ScrollArea' },
{ slug: '/uix/components/separator', label: 'Separator' },
{ slug: '/uix/components/splitter', label: 'Splitter' }
]
}
];

@ -0,0 +1,667 @@
<script lang="ts">
import {
ScrollArea,
type ScrollAreaProps,
type ScrollAreaType
} from '$uix/eidos/components/scroll-area';
import { compileMorfo } from '$uix/morfo';
import { scrollAreaMorfo } from '@/uix/morfo/components/scroll-area';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ───────────────────────────────────────────────────────
const types: ScrollAreaType[] = ['hover', 'scroll', 'auto', 'always'];
let type = $state<ScrollAreaType>('hover');
let scrollHideDelay = $state<number>(600);
type Axis = 'vertical' | 'horizontal' | 'both';
const axes = ['vertical', 'horizontal', 'both'] as const;
let axis = $state<Axis>('vertical');
let itemCount = $state<number>(40);
const areaProps = $derived<Partial<ScrollAreaProps>>({
type,
scrollHideDelay
});
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(scrollAreaMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$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();
});
const sampleItems = $derived(
Array.from({ length: itemCount }, (_, i) => `Item ${String(i + 1).padStart(2, '0')}`)
);
// ── Snippets ─────────────────────────────────────────────────────────
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as ScrollArea from '$soma/components/scroll-area';",
'</' + 'script>',
'',
'<ScrollArea.Provider',
type !== 'hover' && ` type="${type}"`,
scrollHideDelay !== 600 && ` scrollHideDelay={${scrollHideDelay}}`,
'>',
' <ScrollArea.Viewport>',
' <!-- scrollable content -->',
' </ScrollArea.Viewport>',
(axis === 'vertical' || axis === 'both') && ' <ScrollArea.Scrollbar orientation="vertical">',
(axis === 'vertical' || axis === 'both') && ' <ScrollArea.Thumb />',
(axis === 'vertical' || axis === 'both') && ' </ScrollArea.Scrollbar>',
(axis === 'horizontal' || axis === 'both') && ' <ScrollArea.Scrollbar orientation="horizontal">',
(axis === 'horizontal' || axis === 'both') && ' <ScrollArea.Thumb />',
(axis === 'horizontal' || axis === 'both') && ' </ScrollArea.Scrollbar>',
axis === 'both' && ' <ScrollArea.Corner />',
'</ScrollArea.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { ScrollArea } from '$uix/eidos/components/scroll-area';",
'</' + 'script>',
'',
'<ScrollArea',
type !== 'hover' && ` type="${type}"`,
scrollHideDelay !== 600 && ` scrollHideDelay={${scrollHideDelay}}`,
'>',
' <ScrollArea.Viewport>',
' <!-- scrollable content -->',
' </ScrollArea.Viewport>',
(axis === 'vertical' || axis === 'both') && ' <ScrollArea.Scrollbar orientation="vertical">',
(axis === 'vertical' || axis === 'both') && ' <ScrollArea.Thumb />',
(axis === 'vertical' || axis === 'both') && ' </ScrollArea.Scrollbar>',
(axis === 'horizontal' || axis === 'both') && ' <ScrollArea.Scrollbar orientation="horizontal">',
(axis === 'horizontal' || axis === 'both') && ' <ScrollArea.Thumb />',
(axis === 'horizontal' || axis === 'both') && ' </ScrollArea.Scrollbar>',
axis === 'both' && ' <ScrollArea.Corner />',
'</ScrollArea>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · ScrollArea</div>
<h1 data-uix-page-title>ScrollArea</h1>
<p data-uix-page-lede>
Custom-scrollbar container. Hides the native scrollbar and renders a custom-painted track +
thumb on top of a scrollable viewport. The headless <span data-uix-layer-badge="soma">soma</span>
layer owns the scroll math, the visibility timer (<code>type</code>) and the thumb
position; Eidos paints chrome only. Compound API mirrors Radix Primitives: explicit
<code>Viewport</code> / <code>Scrollbar</code> / <code>Thumb</code> / <code>Corner</code>
composition.
</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>type</span>{type}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>{scrollAreaMorfo.scope.join(', ')}
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div
style="inline-size: 100%; max-inline-size: 32rem; block-size: 16rem; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default); overflow: hidden;"
>
<ScrollArea {...areaProps} style="inline-size: 100%; block-size: 100%;">
<ScrollArea.Viewport style="inline-size: 100%; block-size: 100%;">
<div
style={`padding: var(--space-4); ${axis === 'horizontal' || axis === 'both' ? 'inline-size: 48rem;' : ''}`}
>
<h3
style="margin: 0 0 var(--space-2) 0; color: var(--color-content-primary); font-weight: 600;"
>
Sample list — {itemCount} items
</h3>
<p
style="margin: 0 0 var(--space-3) 0; color: var(--color-content-secondary); font-size: var(--font-size-sm);"
>
Scroll inside the framed box to observe the custom scrollbar painted by Eidos.
The thumb follows the soma-tracked scroll position; the track hover state is
driven by the morfo's <code>data-hover</code> marker.
</p>
<ul style="margin: 0; padding-inline-start: var(--space-5); color: var(--color-content-primary);">
{#each sampleItems as item}
<li style="padding: var(--space-1) 0;">{item} — body text continues across the line for horizontal scroll demos when the axis is wide enough.</li>
{/each}
</ul>
</div>
</ScrollArea.Viewport>
{#if axis === 'vertical' || axis === 'both'}
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
{/if}
{#if axis === 'horizontal' || axis === 'both'}
<ScrollArea.Scrollbar orientation="horizontal">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
{/if}
{#if axis === 'both'}
<ScrollArea.Corner />
{/if}
</ScrollArea>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>type</span>
<span>{type}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>axis</span> {axis} ·
<span data-uix-stage-trace-key>hideDelay</span> {scrollHideDelay}ms
</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>3</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>
All knobs are <span data-uix-layer-badge="soma">soma</span> props — the headless layer
owns visibility timing and scroll math. The <span data-uix-layer-badge="eidos">eidos</span>
layer paints chrome with no additional props; tweak the recipe via the
<code>--scroll-area-*</code> custom properties.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>type</span>
<span data-uix-chips role="radiogroup">
{#each types as opt}
<button data-uix-chip data-active={type === opt} onclick={() => (type = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>scrollHideDelay
<span data-uix-control-hint>ms before hiding (hover / scroll only)</span></span
>
<input type="range" min="100" max="2000" step="100" bind:value={scrollHideDelay} />
<span data-uix-control-value>{scrollHideDelay}ms</span>
</label>
</div>
<div data-uix-subsection-head>Composition · which scrollbars to mount</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>axis</span>
<span data-uix-chips role="radiogroup">
{#each axes as opt}
<button data-uix-chip data-active={axis === opt} onclick={() => (axis = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>itemCount <span data-uix-control-hint>list length — drives overflow</span></span
>
<input type="range" min="5" max="120" step="5" bind:value={itemCount} />
<span data-uix-control-value>{itemCount}</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · scroll math + visibility timer</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 · pure pass-through of soma; recipe paints chrome</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</h2>
<p data-uix-section-desc>
Pass-through from soma — no evaluative additions on the eidos layer. Visual tuning lives
in the recipe via <code>--scroll-area-*</code> custom properties.
</p>
<div data-uix-subsection-head>ScrollArea (root)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr>
<td class="name">type</td>
<td class="type">{types.map((t) => `'${t}'`).join(' | ')}</td>
<td class="default">'hover'</td>
<td>Visibility behavior of the scrollbars.</td>
</tr>
<tr>
<td class="name">scrollHideDelay</td>
<td class="type">number</td>
<td class="default">600</td>
<td>ms before hiding (only for 'hover' and 'scroll').</td>
</tr>
<tr>
<td class="name">dir</td>
<td class="type">'ltr' | 'rtl'</td>
<td class="default">from Soma</td>
<td>Text direction. Affects horizontal scrollbar behavior.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ScrollArea.Scrollbar</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr>
<td class="name">orientation</td>
<td class="type">'vertical' | 'horizontal'</td>
<td class="default empty">—</td>
<td>Required. Drives the recipe's <code>data-orientation</code> selector.</td>
</tr>
<tr>
<td class="name">forceMount</td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Keep in DOM when hidden. Enables CSS transitions.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>CSS tokens</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Token</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">--scroll-area-scrollbar-size</td>
<td class="type">var(--space-2-5)</td>
<td>Inline-axis size of the track (and block-axis for horizontal).</td>
</tr>
<tr>
<td class="name">--scroll-area-track-bg</td>
<td class="type">color-mix(--color-neutral-track)</td>
<td>Track background, also used by the Corner.</td>
</tr>
<tr>
<td class="name">--scroll-area-thumb-bg</td>
<td class="type">var(--color-content-muted)</td>
<td>Thumb fill; hover state lifts to <code>--color-content-secondary</code>.</td>
</tr>
<tr>
<td class="name">--scroll-area-thumb-min-size</td>
<td class="type">var(--space-3)</td>
<td>Floor on the thumb so it stays grabable even on long content.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr>
</thead>
<tbody>
<tr>
<td class="name">radix-primitives</td>
<td><code>&lt;ScrollArea&gt;</code></td>
<td>Identical compound shape. UIX adds bare token names + soma layer.</td>
</tr>
<tr>
<td class="name">bits-ui</td>
<td><code>&lt;ScrollArea&gt;</code></td>
<td>Same — UIX matches the Svelte-native shape.</td>
</tr>
<tr>
<td class="name">react-aria-components</td>
<td>n/a (uses native scrolling + virtualization)</td>
<td>UIX paints chrome; React Aria leaves it to the OS / browser.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/scroll-area.ts</code>. Scope is
<code>['soma']</code> — the headless layer is the source of state. Five parts; no
events (see Sema tab).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{scrollAreaMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{scrollAreaMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{scrollAreaMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>role</th>
<th>archetype</th>
<th>states</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="type">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each scrollAreaMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{
attr: string;
value: { kind: string };
condition?: { when: string; prop?: string; part?: string };
severity?: string;
}>;
}}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{#if dataAttrs.length || ariaAttrs.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default"
>{'value' in attr && attr.value
? (attr as { value: { kind: string } }).value.kind
: '—'}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
ScrollArea declares no semantic events. Scrolling is continuous and non-evaluative —
there is no commit / emerge / contact verb to project onto a perceptual channel. The
Scrollbar's <code>data-state</code> visibility transition is a CSS concern, not a sema
event. Consumers that need to react to scroll position read <code>data-at-top</code> /
<code>data-at-bottom</code> / <code>data-at-left</code> / <code>data-at-right</code> on
the Viewport — see the morfo declaration above.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/scroll-area/scroll-area.css</code>. The
<code>data-orientation</code> attribute drives the geometry of the scrollbar; the
visibility states (<code>data-state='visible'</code> / <code>'hidden'</code>) come from
the soma timer.
</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-scroll-area]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Sets up the relative-positioned shell.</td>
</tr>
<tr>
<td class="name"><code>[data-scroll-area-viewport]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Viewport marker. Receives <code>overflow: auto</code> from soma.</td>
</tr>
<tr>
<td class="name"
><code>[data-scroll-area-scrollbar][data-orientation='vertical']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Vertical track sizing.</td>
</tr>
<tr>
<td class="name"
><code>[data-scroll-area-scrollbar][data-orientation='horizontal']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Horizontal track sizing.</td>
</tr>
<tr>
<td class="name"
><code>[data-scroll-area-scrollbar][data-state='hidden']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Visibility fade for the auto-hide timer.</td>
</tr>
<tr>
<td class="name"><code>[data-scroll-area-thumb]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Painted thumb; position is set inline by soma.</td>
</tr>
<tr>
<td class="name"><code>[data-scroll-area-corner]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Square between the two scrollbars when both axes scroll.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The custom scrollbar must remain reachable to keyboard and screen-reader users. The morfo
stamps the WAI-ARIA <code>scrollbar</code> role on every scrollbar with
<code>aria-valuenow</code> / <code>aria-valuemin</code> / <code>aria-valuemax</code>
bound to the soma-tracked scroll percentage.
</p>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">scrollbar</td><td>role</td><td class="type">'scrollbar'</td></tr>
<tr>
<td class="name">scrollbar</td><td>aria-controls</td>
<td class="type">id of viewport</td>
</tr>
<tr>
<td class="name">scrollbar</td><td>aria-orientation</td>
<td class="type">'vertical' | 'horizontal'</td>
</tr>
<tr>
<td class="name">scrollbar</td><td>aria-valuenow / min / max</td>
<td class="type">scroll percent (0–100)</td>
</tr>
<tr>
<td class="name">scrollbar</td><td>aria-label</td>
<td class="type">'Vertical scrollbar' / 'Horizontal scrollbar'</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Other concerns</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Keyboard</td>
<td>
The viewport accepts native scroll keys (Arrow / Page / Home / End) when
focused. Browser keyboard handling is preserved; UIX does not steal arrow keys.
</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>
<code>:focus-visible</code> on the scrollbar exposes the canonical UIX focus
ring.
</td>
</tr>
<tr>
<td class="name">Touch</td>
<td>
The Scrollbar declares <code>touch-action: none</code> so touch drags on the
custom thumb don't conflict with browser-native gestures.
</td>
</tr>
<tr>
<td class="name">RTL</td>
<td>
Soma's <code>dir</code> prop is honoured: in RTL, the vertical scrollbar moves
to the inline-start side, matching native scrollbar positioning.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,645 @@
<script lang="ts">
import {
Separator,
type SeparatorOrientation,
type SeparatorProps
} from '$uix/eidos/components/separator';
import { compileMorfo } from '$uix/morfo';
import { separatorMorfo } from '@/uix/morfo/components/separator';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ───────────────────────────────────────────────────────
const orientations: SeparatorOrientation[] = ['horizontal', 'vertical'];
let orientation = $state<SeparatorOrientation>('horizontal');
let decorative = $state<boolean>(false);
type Demo = 'stacked' | 'toolbar' | 'menu';
const demoOptions = ['stacked', 'toolbar', 'menu'] as const;
let demo = $state<Demo>('stacked');
let thickness = $state<number>(1);
type ColorOption = 'default' | 'subtle' | 'strong';
const colorOptions = ['default', 'subtle', 'strong'] as const;
let color = $state<ColorOption>('default');
const separatorProps = $derived<Partial<SeparatorProps>>({
orientation,
decorative
});
const colorVar = $derived(
color === 'default'
? 'var(--color-border-default)'
: color === 'subtle'
? 'var(--color-border-subtle)'
: 'var(--color-border-strong)'
);
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(separatorMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$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();
});
// ── Snippets ─────────────────────────────────────────────────────────
const somaSnippet = $derived(
[
'<!-- Separator is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<div',
' data-separator',
` data-orientation="${orientation}"`,
decorative ? ' aria-hidden="true"' : ' role="separator"',
decorative ? '' : ` aria-orientation="${orientation}"`,
'></div>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Separator } from '$uix/eidos/components/separator';",
'</' + 'script>',
'',
'<Separator',
orientation !== 'horizontal' && ` orientation="${orientation}"`,
decorative && ' decorative',
'/>'
]
.filter(Boolean)
.join('\n')
);
const styleOverride = $derived(
[
`--separator-thickness: ${thickness}px;`,
`--separator-color: ${colorVar};`
].join(' ')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · Separator</div>
<h1 data-uix-page-title>Separator</h1>
<p data-uix-page-lede>
Visual divider between sections — a thin rule painted in the muted border color. The
<code>orientation</code> prop drives the geometry; <code>decorative</code> toggles between a
semantic
<code>role="separator"</code> + <code>aria-orientation</code> and a purely visual
<code>aria-hidden</code>. Eidos-native primitive: no soma backing, no semantic events. Logical
sizing (<code>inline-size</code> / <code>block-size</code>) so the rule mirrors automatically
in RTL.
</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>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>role</span>{decorative ? '— (decorative)' : 'separator'}
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
{#if demo === 'stacked'}
<div
style="display: flex; flex-direction: column; gap: var(--space-3); inline-size: 100%; max-inline-size: 32rem; padding: var(--space-4); border-radius: var(--radius-md); border: 1px dashed var(--color-border-default);"
>
<div style="color: var(--color-content-primary); font-weight: 600;">Account</div>
<div style="color: var(--color-content-secondary); font-size: var(--font-size-sm);">
Personal information and password.
</div>
<Separator {...separatorProps} style={styleOverride} />
<div style="color: var(--color-content-primary); font-weight: 600;">Notifications</div>
<div style="color: var(--color-content-secondary); font-size: var(--font-size-sm);">
Email, SMS, and in-app delivery preferences.
</div>
<Separator {...separatorProps} style={styleOverride} />
<div style="color: var(--color-content-primary); font-weight: 600;">Danger zone</div>
<div style="color: var(--color-content-secondary); font-size: var(--font-size-sm);">
Export data, delete account.
</div>
</div>
{:else if demo === 'toolbar'}
<div
style="display: inline-flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); border: 1px dashed var(--color-border-default); background: var(--color-surface-default);"
>
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Bold</button>
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Italic</button>
<Separator orientation="vertical" {decorative} style={styleOverride} />
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Left</button>
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Center</button>
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Right</button>
<Separator orientation="vertical" {decorative} style={styleOverride} />
<button style="padding: 0 var(--space-2); background: transparent; border: 0; cursor: pointer;">Link</button>
</div>
{:else}
<div
style="inline-size: 100%; max-inline-size: 16rem; padding: var(--space-2); border-radius: var(--radius-md); border: 1px dashed var(--color-border-default); background: var(--color-surface-overlay);"
>
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">New file</button>
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">Open</button>
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">Open recent</button>
<Separator {...separatorProps} style={styleOverride} />
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">Save</button>
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer;">Save as…</button>
<Separator {...separatorProps} style={styleOverride} />
<button style="display: block; inline-size: 100%; text-align: start; padding: var(--space-2) var(--space-3); background: transparent; border: 0; cursor: pointer; color: var(--color-loss-text);">Quit</button>
</div>
{/if}
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>orientation</span>
<span>{orientation}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>decorative</span> {String(decorative)} ·
<span data-uix-stage-trace-key>thickness</span> {thickness}px
</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>2</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 · 0e</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>
Separator is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. Two
public props drive geometry + accessibility; tokens override thickness and color via the
<code>style</code> escape.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual + a11y treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each orientations as opt}
<button
data-uix-chip
data-active={orientation === opt}
onclick={() => (orientation = opt)}>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>decorative</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={decorative} />
<span data-uix-switch-label>{decorative ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>thickness <span data-uix-control-hint
>pixels → <code>--separator-thickness</code></span
></span
>
<input type="range" min="1" max="6" step="1" bind:value={thickness} />
<span data-uix-control-value>{thickness}px</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>color <span data-uix-control-hint
>token chip → <code>--separator-color</code></span
></span
>
<span data-uix-chips role="radiogroup">
{#each colorOptions as opt}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>demo content</span>
<span data-uix-chips role="radiogroup">
{#each demoOptions as opt}
<button data-uix-chip data-active={demo === opt} onclick={() => (demo = opt)}
>{opt}</button
>
{/each}
</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · separator is eidos-native — equivalent markup shown</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 · orientation drives geometry; decorative drops the role</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</h2>
<p data-uix-section-desc>
Two public props on the root. Visual tuning (thickness, color) is exposed via CSS custom
properties (<code>--separator-thickness</code> / <code>--separator-color</code>) rather
than typed props — there is no consumer-driven reason to manufacture a typed surface
around a single CSS value.
</p>
<div data-uix-subsection-head>Separator props</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">orientation</td>
<td class="type">{orientations.join(' | ')}</td>
<td class="default">'horizontal'</td>
<td>
Layout axis. Maps to <code>inline-size</code> / <code>block-size</code>. Responsive
via <code>ResponsiveProp&lt;SeparatorOrientation&gt;</code>.
</td>
</tr>
<tr>
<td class="name">decorative</td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>
When true, drops <code>role="separator"</code> + <code>aria-orientation</code>
and adds <code>aria-hidden="true"</code>.
</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>CSS tokens</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Token</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">--separator-thickness</td>
<td class="type">var(--border-width)</td>
<td>Block-axis size on horizontal, inline-axis size on vertical.</td>
</tr>
<tr>
<td class="name">--separator-color</td>
<td class="type">var(--color-border-default)</td>
<td>Background of the rule. Use one of the canonical border colors.</td>
</tr>
<tr>
<td class="name">--separator-min-length</td>
<td class="type">var(--space-4)</td>
<td>Vertical separator's minimum block-axis size when stretched.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr>
</thead>
<tbody>
<tr>
<td class="name">radix-primitives</td>
<td><code>&lt;Separator&gt;</code></td>
<td>
Identical contract — <code>orientation</code> + <code>decorative</code>. UIX adds
responsive orientation + logical CSS axes.
</td>
</tr>
<tr>
<td class="name">mantine</td>
<td><code>&lt;Divider&gt;</code></td>
<td>
Mantine adds <code>label</code> and <code>variant</code> (dashed / dotted); UIX
defers those to consumer composition + style overrides.
</td>
</tr>
<tr>
<td class="name">chakra-ui</td>
<td><code>&lt;Divider&gt;</code></td>
<td>
Chakra mixes the prop with the style-prop system; UIX keeps it pure layout — no
theme-coloured variants on the divider itself.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/separator.ts</code>. Eidos-native (scope:
<code>['eidos']</code>). The Provider part declares conditional ARIA so the same DOM
element flips between semantic and decorative modes based on the
<code>decorative</code> prop.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{separatorMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{separatorMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{separatorMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</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.archetype}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each separatorMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{
attr: string;
value: { kind: string };
condition?: { when: string; prop?: string; part?: string };
severity?: string;
}>;
}}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{#if dataAttrs.length || ariaAttrs.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default"
>{'value' in attr && attr.value
? (attr as { value: { kind: string } }).value.kind
: '—'}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead>
<tr><th>aria-attr</th><th>Source kind</th><th>Condition</th><th>Severity</th></tr>
</thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default"
>{a.condition
? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}${a.condition.part ? ` (${a.condition.part})` : ''}`
: 'always'}</td
>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
Separator declares no semantic events. As a passive layout primitive it paints a rule and
nothing else — no commit, no emerge, no contact. Components that animate sections (e.g.
Collapsible toggling visibility around a separator) own the relevant sema verbs in their
own morfo. The morfo is declared with <code>scope: ['eidos']</code> and the README's
Passive justification section makes the rationale explicit.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/separator/separator.css</code>. The
<code>data-orientation</code> attribute switches between horizontal and vertical sizing;
logical CSS axes (<code>inline-size</code> / <code>block-size</code>) make the rule
mirror in RTL.
</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-separator]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Emitted by the wrapper on the single element.</td>
</tr>
<tr>
<td class="name"
><code>{`[data-separator] { background: var(--separator-color, var(--color-border-default)); flex-shrink: 0; border: 0; }`}</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Base painting + shrink protection inside flex parents.</td>
</tr>
<tr>
<td class="name"
><code>[data-separator][data-orientation='horizontal']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>
<code>inline-size: 100%</code> + <code>block-size:
var(--separator-thickness)</code>.
</td>
</tr>
<tr>
<td class="name"
><code>[data-separator][data-orientation='vertical']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>
<code>inline-size: var(--separator-thickness)</code> +
<code>block-size: 100%</code> + <code>min-block-size</code> safeguard.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The conditional ARIA contract honours the WAI-ARIA <code>separator</code> role when
<code>decorative</code> is false (default). When true the element drops the role and is
hidden from assistive tech, matching Radix Primitives' contract.
</p>
<div data-uix-subsection-head>Roles</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Mode</th><th>Role</th><th>aria-orientation</th><th>aria-hidden</th></tr></thead>
<tbody>
<tr>
<td class="name">semantic (default)</td>
<td class="type">'separator'</td>
<td class="type">orientation</td>
<td class="default empty">—</td>
</tr>
<tr>
<td class="name">decorative</td>
<td class="default empty">—</td>
<td class="default empty">—</td>
<td class="type">'true'</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Other concerns</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Keyboard</td>
<td>Not focusable. Tab order follows surrounding content.</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>No focus ring; the element never receives focus.</td>
</tr>
<tr>
<td class="name">Color contrast</td>
<td>
Default color is <code>--color-border-default</code>, which meets 3:1 against
the canonical surface tokens in both light and dark themes.
</td>
</tr>
<tr>
<td class="name">RTL</td>
<td>
Logical CSS axes mean the divider doesn't need to know about direction — the
horizontal rule fills the inline axis, which flips under <code>dir="rtl"</code>
automatically.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,824 @@
<script lang="ts">
import {
Splitter,
type SplitterProps
} from '$uix/eidos/components/splitter';
import { compileMorfo } from '$uix/morfo';
import { splitterMorfo } from '@/uix/morfo/components/splitter';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ───────────────────────────────────────────────────────
type Orientation = 'horizontal' | 'vertical';
const orientations: Orientation[] = ['horizontal', 'vertical'];
let orientation = $state<Orientation>('horizontal');
let disabled = $state(false);
let keyboardStep = $state<number>(1);
let collapsible = $state(false);
let panelACount = $state<number>(40);
let panelBCount = $state<number>(60);
const splitterProps = $derived<Partial<SplitterProps>>({
orientation,
disabled,
keyboardStep
});
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(splitterMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$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')}`;
}
// ── Snippets ─────────────────────────────────────────────────────────
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as Splitter from '$soma/components/splitter';",
'</' + 'script>',
'',
'<Splitter.Provider',
orientation !== 'horizontal' && ` orientation="${orientation}"`,
disabled && ' disabled',
keyboardStep !== 1 && ` keyboardStep={${keyboardStep}}`,
'>',
' <Splitter.Panel panelId="a" defaultSize={50} minSize={20}' +
(collapsible ? ' collapsible collapsedSize={0}' : '') +
'>',
' {a}',
' </Splitter.Panel>',
' <Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />',
' <Splitter.Panel panelId="b" defaultSize={50} minSize={20}>',
' {b}',
' </Splitter.Panel>',
'</Splitter.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Splitter } from '$uix/eidos/components/splitter';",
'</' + 'script>',
'',
'<Splitter',
orientation !== 'horizontal' && ` orientation="${orientation}"`,
disabled && ' disabled',
keyboardStep !== 1 && ` keyboardStep={${keyboardStep}}`,
'>',
' <Splitter.Panel panelId="a" defaultSize={50} minSize={20}' +
(collapsible ? ' collapsible collapsedSize={0}' : '') +
'>',
' {a}',
' </Splitter.Panel>',
' <Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />',
' <Splitter.Panel panelId="b" defaultSize={50} minSize={20}>',
' {b}',
' </Splitter.Panel>',
'</Splitter>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · Splitter</div>
<h1 data-uix-page-title>Splitter</h1>
<p data-uix-page-lede>
Two-or-more resizable panels separated by a draggable handle. Headless soma owns the resize
math (percentages, min / max clamping, collapsed state, keyboard arrow steps). The morfo
declares a single semantic event — <code>commit-resize</code> in the <code>commit</code>
family — fired on drag-end so perceptual channels react to the settled value, not to every
pixel of the drag. Eidos paints chrome plus a pulse on the handle when the commit fires.
</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>orientation</span>{orientation}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>{splitterMorfo.scope.join(', ')}
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div
style="inline-size: 100%; max-inline-size: 36rem; block-size: 18rem;"
>
<Splitter {...splitterProps} style="inline-size: 100%; block-size: 100%;">
<Splitter.Panel
panelId="a"
defaultSize={50}
minSize={20}
collapsible={collapsible}
collapsedSize={0}
>
<div
style="block-size: 100%; padding: var(--space-3); overflow: auto; color: var(--color-content-primary); background: var(--color-primary-track);"
>
<strong>Panel A</strong>
<p style="margin: var(--space-2) 0; color: var(--color-content-secondary); font-size: var(--font-size-sm);">
Drag the handle to resize, use arrow keys when focused. Home jumps to min, End
to max. {collapsible ? 'Enter toggles collapse.' : ''}
</p>
<ul style="margin: 0; padding-inline-start: var(--space-5);">
{#each Array.from({ length: panelACount }, (_, i) => i + 1) as i}
<li>Row A-{i}</li>
{/each}
</ul>
</div>
</Splitter.Panel>
<Splitter.ResizeTrigger panelBefore="a" panelAfter="b" />
<Splitter.Panel panelId="b" defaultSize={50} minSize={20}>
<div
style="block-size: 100%; padding: var(--space-3); overflow: auto; color: var(--color-content-primary); background: var(--color-affirm-track);"
>
<strong>Panel B</strong>
<p style="margin: var(--space-2) 0; color: var(--color-content-secondary); font-size: var(--font-size-sm);">
The handle paints in the primary color while dragging; on commit (mouse up) it
pulses for the sema signal-hold duration.
</p>
<ul style="margin: 0; padding-inline-start: var(--space-5);">
{#each Array.from({ length: panelBCount }, (_, i) => i + 1) as i}
<li>Row B-{i}</li>
{/each}
</ul>
</div>
</Splitter.Panel>
</Splitter>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>drag the handle or hit ▶ play on the Sema tab to see the commit-resize event</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>orientation</span> {orientation} ·
<span data-uix-stage-trace-key>step</span> {keyboardStep}%
</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>3</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>
Soma owns every knob — the eidos layer adds zero evaluative props. Sema events are
surfaced in the Sema tab; the stage trace shows them too once a drag commits or the play
button fires.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each orientations as opt}
<button
data-uix-chip
data-active={orientation === opt}
onclick={() => (orientation = opt)}>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>keyboardStep <span data-uix-control-hint
>percent per arrow-key press</span
></span
>
<input type="range" min="1" max="10" step="1" bind:value={keyboardStep} />
<span data-uix-control-value>{keyboardStep}%</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>collapsible (panel A) <span data-uix-control-hint
>Enter toggles collapse to <code>collapsedSize=0</code></span
></span
>
<span data-uix-switch>
<input type="checkbox" bind:checked={collapsible} />
<span data-uix-switch-label>{collapsible ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>Demo content</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>panel A rows</span>
<input type="range" min="10" max="120" step="10" bind:value={panelACount} />
<span data-uix-control-value>{panelACount}</span>
</label>
<label data-uix-control>
<span data-uix-control-label>panel B rows</span>
<input type="range" min="10" max="120" step="10" bind:value={panelBCount} />
<span data-uix-control-value>{panelBCount}</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · resize math + keyboard + collapse</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 · pure pass-through; recipe pulses on commit-resize</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</h2>
<p data-uix-section-desc>
Pass-through from soma — no evaluative additions on the eidos layer.
</p>
<div data-uix-subsection-head>Splitter (root)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr>
<td class="name">orientation</td>
<td class="type">'horizontal' | 'vertical'</td>
<td class="default">'horizontal'</td>
<td>Layout direction.</td>
</tr>
<tr>
<td class="name">disabled</td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Disables all resize triggers.</td>
</tr>
<tr>
<td class="name">keyboardStep</td>
<td class="type">number</td>
<td class="default">1</td>
<td>Percentage of resize per arrow-key press.</td>
</tr>
<tr>
<td class="name">onResize</td>
<td class="type">(sizes: number[]) =&gt; void</td>
<td class="default empty">—</td>
<td>Live callback during drag (every frame).</td>
</tr>
<tr>
<td class="name">onResizeEnd</td>
<td class="type">(sizes: number[]) =&gt; void</td>
<td class="default empty">—</td>
<td>Drag-end callback. Also fires <code>commit-resize</code> in sema.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Splitter.Panel</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr>
<td class="name">panelId</td>
<td class="type">string</td>
<td class="default empty">—</td>
<td>Required. Links the panel with its surrounding resize triggers.</td>
</tr>
<tr>
<td class="name">defaultSize</td>
<td class="type">number (0–100)</td>
<td class="default empty">—</td>
<td>Required. Initial size as a percentage.</td>
</tr>
<tr>
<td class="name">minSize</td>
<td class="type">number</td>
<td class="default">0</td>
<td>Lower clamp on the panel's size.</td>
</tr>
<tr>
<td class="name">maxSize</td>
<td class="type">number</td>
<td class="default">100</td>
<td>Upper clamp on the panel's size.</td>
</tr>
<tr>
<td class="name">collapsible</td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Allows double-click and Enter to toggle collapse.</td>
</tr>
<tr>
<td class="name">collapsedSize</td>
<td class="type">number</td>
<td class="default">0</td>
<td>Size when collapsed (percentage).</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Splitter.ResizeTrigger</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr>
<td class="name">panelBefore</td>
<td class="type">string</td>
<td class="default empty">—</td>
<td>Required. Panel ID that grows when the handle is dragged forward.</td>
</tr>
<tr>
<td class="name">panelAfter</td>
<td class="type">string</td>
<td class="default empty">—</td>
<td>Required. Panel ID that shrinks when the handle is dragged forward.</td>
</tr>
<tr>
<td class="name">disabled</td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Disables this specific resize trigger.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr>
</thead>
<tbody>
<tr>
<td class="name">radix-themes</td>
<td><code>&lt;Splitter&gt;</code></td>
<td>Identical compound shape. UIX adds collapsible panels + sema commit event.</td>
</tr>
<tr>
<td class="name">ark-ui</td>
<td><code>Splitter</code> headless</td>
<td>Soma layer mirrors Ark UI's headless contract closely.</td>
</tr>
<tr>
<td class="name">react-resizable-panels</td>
<td><code>&lt;PanelGroup&gt; / &lt;Panel&gt; / &lt;PanelResizeHandle&gt;</code></td>
<td>
UIX wraps the equivalent shape with a more compact naming
(<code>Panel</code> / <code>ResizeTrigger</code>).
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/splitter.ts</code>. Scope is
<code>['soma']</code> — the headless layer owns state. Three parts, one event.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{splitterMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{splitterMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{splitterMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts.length</td><td>{partsList.length}</td></tr>
<tr><td class="name">events.length</td><td>{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>role</th>
<th>archetype</th>
<th>states</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="type">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each splitterMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{
attr: string;
value: { kind: string };
condition?: { when: string; prop?: string; part?: string };
severity?: string;
}>;
keyboard?: ReadonlyArray<{ key: string; action: string }>;
}}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{@const keyboard = partAny.keyboard ?? []}
{#if dataAttrs.length || ariaAttrs.length || keyboard.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default"
>{'value' in attr && attr.value
? (attr as { value: { kind: string } }).value.kind
: '—'}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if keyboard.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>key</th><th>action</th></tr></thead>
<tbody>
{#each keyboard as k}
<tr>
<td><span data-uix-kbd>{k.key}</span></td>
<td>{k.action}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<tbody>
{#each events as action}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const intentStr =
typeof intentDecl === 'string'
? intentDecl
: intentDecl
? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}`
: '—'}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{intentStr}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events + perceptual signature
</h2>
<p data-uix-section-desc>
A single <code>commit-resize</code> event in the <code>commit</code> family. Sequence is
<em>post</em> — the perceptual signal lands AFTER the resize math has settled. Press ▶
play to emit the event on the live preview's resize trigger; the stage trace strip records
it and the recipe pulses the handle for the signal-hold duration.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<tbody>
{#each events as action}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const effectiveIntent =
typeof intentDecl === 'string'
? intentDecl
: intentDecl
? undefined
: undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{typeof intentDecl === 'string' ? intentDecl : '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector(
'[data-splitter-resize-trigger]'
) ?? stageRef?.querySelector('[data-splitter]')) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}>▶ 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>
Recipe lives in <code>src/uix/eidos/components/splitter/splitter.css</code>. The
<code>data-orientation</code> attribute drives the flex direction; the
<code>data-dragging</code> presence flag swaps the handle to the active color; the
<code>data-event='commit-resize'</code> hook pulses on commit.
</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-splitter]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Sets up the flex container.</td>
</tr>
<tr>
<td class="name"
><code>[data-splitter][data-orientation='vertical']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Swaps to <code>flex-direction: column</code>.</td>
</tr>
<tr>
<td class="name"><code>[data-splitter-panel]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Panel marker. Soma sets the inline / block size via inline style.</td>
</tr>
<tr>
<td class="name"><code>[data-splitter-resize-trigger]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Handle marker. Paints the drag affordance.</td>
</tr>
<tr>
<td class="name"
><code>[data-splitter-resize-trigger][data-dragging]</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Active drag — paint in <code>--color-primary-solid</code>.</td>
</tr>
<tr>
<td class="name"
><code>[data-splitter-resize-trigger][data-event='commit-resize']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>
Sema hook — pulse the handle during the signal-hold (sequence:
<em>post</em>).
</td>
</tr>
<tr>
<td class="name"
><code>[data-splitter-resize-trigger]::before</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Center grip — small pill sized to match orientation.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
The resize trigger advertises <code>role="separator"</code> with
<code>aria-valuenow</code> / <code>aria-valuemin</code> / <code>aria-valuemax</code>
bound to the soma-tracked size, plus <code>aria-orientation</code> matching the
splitter's layout.
</p>
<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>
<tr><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowRight</span></td><td>Resize by <code>keyboardStep</code> percent (horizontal).</td></tr>
<tr><td><span data-uix-kbd>ArrowUp</span> / <span data-uix-kbd>ArrowDown</span></td><td>Resize by <code>keyboardStep</code> percent (vertical).</td></tr>
<tr><td><span data-uix-kbd>Home</span></td><td>Jump to the minimum size.</td></tr>
<tr><td><span data-uix-kbd>End</span></td><td>Jump to the maximum size.</td></tr>
<tr><td><span data-uix-kbd>Enter</span></td><td>Toggle collapse (when <code>collapsible</code>).</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">resize-trigger</td><td>role</td><td class="type">'separator'</td></tr>
<tr><td class="name">resize-trigger</td><td>aria-orientation</td><td class="type">'horizontal' | 'vertical'</td></tr>
<tr><td class="name">resize-trigger</td><td>aria-valuenow</td><td class="type">current size %</td></tr>
<tr><td class="name">resize-trigger</td><td>aria-valuemin</td><td class="type">minSize</td></tr>
<tr><td class="name">resize-trigger</td><td>aria-valuemax</td><td class="type">maxSize</td></tr>
<tr><td class="name">resize-trigger</td><td>aria-label</td><td class="type">'Resize panels'</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Other concerns</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Focus visible</td>
<td>
<code>:focus-visible</code> on the resize trigger surfaces the canonical UIX
focus ring.
</td>
</tr>
<tr>
<td class="name">Pointer / touch</td>
<td>
Cursor flips to <code>col-resize</code> / <code>row-resize</code> on the
handle. Soma owns pointer-down / pointer-move / pointer-up.
</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
The handle's hover / drag transition uses <code>--duration-fast</code>; the
commit pulse honours the sema visual channel's reduced-motion override.
</td>
</tr>
<tr>
<td class="name">RTL</td>
<td>
Soma's <code>dir</code> prop swaps the direction of <code>ArrowLeft</code> /
<code>ArrowRight</code> in horizontal splitters so the user always
"grows-forward" with the trailing-edge key.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.