feat(eidos): action components — fab, onion-menu, button-group, split-button

Ship the four action-cluster components. Per the audit the user requested, the
hardcoded magic numbers in their CSS move to recipes (base.ts), so the values
live in one place and pass the recipe-CSS contract:

- fab: the off-ladder Material size / icon / lift scale → `fab` recipe (named
  tokens — the canonical control-height / icon-size ladders genuinely can't
  express 40/56/72 + 20/24/28; documented as the FAB's own scale).
- onion-menu: the radial-chrome magics (sector ring widths, glyph bar, trail
  opacities) → `onion-menu` recipe; the centre trigger's lift + shadow
  REFERENCE `--fab-*` (it IS a FAB — the two can't drift apart).
- button-group: the segmented-divider strength → `button-group` recipe.
- split-button: the slim chevron tail's inline padding → `split-button` recipe;
  DropdownMenu gains `customAnchor` so the menu spans the whole split surface.

All four compose the framework primitives (Group / Button / Icon / DropdownMenu),
declare a morfo, ship langs + a demo, and pass the recipe-CSS contract test (the
only flagged component left is palabras, an active dev track). check 0;
onion-menu engine tests 31/31.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent a60abef4bf
commit c00bac1345

@ -0,0 +1,156 @@
# ButtonGroup
Presentational cluster of independent action **buttons**. `role="group"` +
shared visual defaults that propagate to the child Buttons + segmented
(attached) or spaced layout.
```svelte
<script lang="ts">
import { ButtonGroup } from '$uix/eidos/components/button-group';
import { Button } from '$uix/eidos/components/button';
</script>
<!-- Segmented (attached is ON by default) — children inherit variant/size/color -->
<ButtonGroup aria-label="Text alignment" variant="surface">
<Button>Left</Button>
<Button>Center</Button>
<Button>Right</Button>
</ButtonGroup>
<!-- Spaced, vertical, full-width -->
<ButtonGroup aria-label="Actions" attached={false} orientation="vertical" block>
<Button variant="solid">Save</Button>
<Button variant="outline">Cancel</Button>
</ButtonGroup>
```
The children are real `<Button>` / `<IconButton>` / `<SplitButton>` — there is
**no** `ButtonGroup.Item`. A child's explicit prop always wins over the group
default.
## Baseline
No Air baseline (`air` had no ButtonGroup). The two precedents in the current
codebase:
- **`<Group>`** (`$uix/eidos/components/group`) — the generic layout cluster.
ButtonGroup **composes** it for all layout (attached corner-squaring, border
overlap, direction, equal-width grow). The `attached` mechanism is Group's,
not re-implemented here.
- **`<ToggleGroup>`** — the structural precedent for "a group that propagates
visual knobs to its children via an eidos context" (`context.ts`).
ButtonGroup differs: ToggleGroup is a **selection control** (value, on/off,
roving focus, `commit-toggle` event) with its own `ToggleGroup.Item`;
ButtonGroup is **passive** and takes real standalone Buttons.
## Comparativa
| Capability | Chakra v2 | MUI | Mantine | Bootstrap | Radix Themes | **UIX** |
| --- | --- | --- | --- | --- | --- | --- |
| `role="group"` + accessible name | no (styled Flex) | **yes** | no | **yes** (`aria-label` req.) | — (use Flex) | **yes** (declared in morfo) |
| Propagate `variant`/`size`/`color` to children | **yes** (context) | **yes** (context) | no | no | — | **yes** (eidos context) |
| Propagate `disabled` | yes | yes | no | no | — | yes |
| `attached`/segmented | `isAttached` | default on | default on | default on | `SegmentedControl` (= selection) | **default on** (`attached`) |
| Orientation H/V | yes | yes | yes | yes | — | yes (`aria-orientation`) |
| `block` / fullWidth | flexProps | `fullWidth` | yes | yes | — | yes (`block`) |
| Headless behaviour | no | no | no | no | no | **no** (0 events) |
No reference ships headless behaviour for a button group — Ark UI / React Aria
have only a generic `Group` (role=group). This confirms ButtonGroup is
**presentation + ARIA + prop propagation**, not a soma component.
## Decisiones
- **scope: `['eidos']`** (no soma, no sema). It has no state/events/keyboard, so
a soma provider would be empty. The ARIA contract is declared in the morfo and
materialised by the wrapper — the Icon precedent for an eidos-only primitive
with an a11y contract.
- **Composes `<Group>`** for layout instead of re-implementing the attached
overlap/corner logic (CG §4 — compose existing components). The only recipe
addition is tuning Group's `--group-attached-overlap` to the Button's border
and the focus/active z-index float so segmented buttons aren't clipped.
- **Propagation via an eidos context** that `<Button>` opts into (a small,
backward-compatible change: Button reads an optional group context for its
variant/size/color/disabled defaults; an explicit prop always wins). A CSS
descendant selector cannot reach the attrs Button's recipe reads on the button
element, so the value must travel as a context (same reason ToggleGroup uses a
context for its items). Parity with Chakra/MUI.
- **`attached` ON by default** (segmented look) — matches MUI / Bootstrap /
Mantine. Opt out with `attached={false}` for gap-separated buttons.
- **`equal` (same button width), default-on when `attached`.** Pure CSS can only
equalise on the **cross axis** of a flex line, so the two orientations differ
by necessity: **vertical** stretches each button to the **widest** (the group
stays content-sized — the case that motivated the prop); **horizontal** can't
size the main axis to the widest in pure CSS, so it fills the container and
splits it equally (the same result as `block`). Equalising a horizontal row
to the widest *content* would need JS measurement — deliberately not done for
a layout primitive. The segmented border / radius rules are independent of
`equal`.
- **Segmented seam = one border, no overlap.** Group's generic `attached` uses a
negative-margin overlap that doubles to ~2px under fractional `--scaling`.
ButtonGroup neutralises it (`--group-attached-overlap: 0`) and gives each seam
exactly one 1px border: bordered variants (surface/outline) drop their leading
border (the previous button's trailing border is the seam); border-less
variants draw the `currentColor` divider.
- **`role` fixed to `group`**. For toolbar semantics (roving focus, a single tab
stop) use `<Toolbar>`; ButtonGroup keeps every button independently
tab-focusable (WAI-ARIA: a group of independent actions).
## Subset
ButtonGroup forwards visual knobs to the child Buttons; it owns no `data-color`
recipe of its own.
- **color** (hierarchy, propagated): `primary` (default) · `secondary` ·
`neutral`. Evaluative intents (`affirm`/`fulfill`/`risk`/`threat`/`loss`) are
**per-button**, set on the individual `<Button intent="...">` — the group does
not propagate intent (a group can hold one destructive button; CANON §5
per-event intent).
- **variant** (propagated): the full `ButtonVariant` set (`solid` · `soft` ·
`surface` · `outline` · `ghost` · `plain`). `attached` reads best with the
bordered variants (solid/soft/surface/outline).
- **size** (propagated): `ButtonSize` (`xs..xl`).
## Passive justification
0 semantic events. ButtonGroup is a structural frame: per CANON §5 the
perceptual signal belongs to the act, not the container. Each composed
`<Button>` fires its own `contact-activate`; the group emits nothing. The demo's
trace strip shows the children's `contact-activate` events bubbling through the
stage observer.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `divider` slot between non-attached buttons | **diferir** | Same backlog as Group/Stack; compose a `<Separator>` for now. |
| Propagate `rounded` / `shape` to children | **diferir** | Less commonly group-shared; in attached mode inner radii are squared anyway. Add if a consumer needs a shared pill group. |
| Propagate `intent` | **descartar** | Per-button by doctrine (per-event intent). |
`equal` (every button = the widest) is **implemented** — it's the grid-based
width-matching prop, default-on when `attached`. It supersedes the earlier
`grow` gap.
## Sema events
0 events — passive. See **Passive justification** above. Perceptual feedback
comes from the composed `<Button>` (`contact-activate`, family `contact`).
## Audit exceptions
`npm run component:audit --only button-group` reports two `error` rows that are
**audit limitations for eidos-only (`scope: ['eidos']`) components**, not real
defects — `icon` (the canonical eidos-only primitive) reports the identical two:
- **`E-2.2`** — "css not imported from eidos/index.css". ButtonGroup has its own
`.svelte`, so it is **code-split**: `button-group.svelte` self-imports
`./button-group.css` and the component ships its own chunk. `index.css`
explicitly forbids re-adding code-split recipes there (it would double-load the
CSS). The audit only checks `index.css` membership, so it can't see the
self-import.
- **`D-3.1`** — "Missing somaSnippet". ButtonGroup is eidos-only — there is **no
soma layer**, so the demo ships only the `eidosSnippet` (same as the
`icon` / `group` demos). A soma snippet for a component with no soma provider
would be a lie.
Both are accepted by the eidos-only precedent (`icon`). All other rules pass.

@ -0,0 +1,107 @@
/*
* ButtonGroup recipe.
*
* Composes <Group> for the flex layout + inner-radius squaring (the segmented
* corners). ButtonGroup owns the rest of the segmented treatment because it is
* VARIANT-AWARE in a way Group's generic cluster can't be:
*
* - Group's negative-margin overlap is neutralised (`--group-attached-overlap:
* 0`). Under fractional `--scaling` two overlapping 1px borders render as a
* ~2px doubled seam; instead every seam carries exactly ONE 1px border.
* - bordered variants (surface / outline) drop their LEADING border, so the
* previous button's trailing border is the single seam.
* - border-less variants (solid / soft / ghost / plain) draw an explicit
* subtle divider from the button's own foreground (`currentColor`) — it
* adapts: a light hairline on solid colored fills, a dark hairline on
* soft / ghost / plain light fills.
*
* [data-button-group] → the group (also [data-group][role=group])
* [data-button-group][data-attached] > [data-button] → a segmented child button
*/
/* Neutralise Group's overlap — one 1px border per seam (sub-pixel robust).
* Four of ButtonGroup's own always-present attrs (specificity 0,4,0) win over
* Group's `[data-box][data-flex][data-group]` default (0,3,0) without coupling
* to Box/Flex internals or relying on stylesheet source order. */
[data-button-group][data-attached][data-orientation][data-variant] {
--group-attached-overlap: 0;
}
[data-button-group][data-block] {
inline-size: 100%;
}
/* `equal` — every button the same width. Pure CSS can only equalise on the
* CROSS axis of a flex line, so the two orientations differ (the wrapper
* resolves `equal` to default-on when `attached`):
* - vertical: cross axis IS the width → each button stretches to the WIDEST,
* and the group stays content-sized.
* - horizontal: the width is the MAIN axis (flex can't size it to the widest
* in pure CSS), so `equal` fills the container inline-size and splits it
* equally — the same result as `block`. */
[data-button-group][data-equal][data-orientation='vertical'] {
align-items: stretch;
}
[data-button-group][data-equal][data-orientation='vertical'] > [data-button] {
inline-size: 100%;
}
[data-button-group][data-equal]:not([data-orientation='vertical']) {
inline-size: 100%;
}
[data-button-group][data-equal]:not([data-orientation='vertical']) > [data-button] {
flex: 1 1 0;
min-inline-size: 0;
}
/* Bordered variants (surface / outline): one border per seam — drop the
* leading border on every non-first button so the previous button's trailing
* border is the single seam. */
[data-button-group][data-attached]:not([data-orientation='vertical'])
> [data-button]:is([data-variant='surface'], [data-variant='outline']):not(:first-child) {
border-inline-start-width: 0;
}
[data-button-group][data-attached][data-orientation='vertical']
> [data-button]:is([data-variant='surface'], [data-variant='outline']):not(:first-child) {
border-block-start-width: 0;
}
/* Border-less variants (solid / soft / ghost / plain): explicit subtle divider
* on the leading edge. */
[data-button-group][data-attached]:not([data-orientation='vertical'])
> [data-button]:is(
[data-variant='solid'],
[data-variant='soft'],
[data-variant='ghost'],
[data-variant='plain']
):not(:first-child) {
border-inline-start: var(--button-border-width) solid
color-mix(in srgb, currentColor var(--button-group-divider-strength), transparent);
}
[data-button-group][data-attached][data-orientation='vertical']
> [data-button]:is(
[data-variant='solid'],
[data-variant='soft'],
[data-variant='ghost'],
[data-variant='plain']
):not(:first-child) {
border-block-start: var(--button-border-width) solid
color-mix(in srgb, currentColor var(--button-group-divider-strength), transparent);
}
/* ── Float interactive button above neighbours so its ring isn't clipped ── */
[data-button-group][data-attached] [data-button]:hover:not([data-disabled]) {
z-index: 1;
}
[data-button-group][data-attached] [data-button]:active:not([data-disabled]) {
z-index: 1;
}
[data-button-group][data-attached] [data-button]:focus-visible {
z-index: 2;
}

@ -0,0 +1,80 @@
<script lang="ts">
import './button-group.css';
/**
* Eidos <ButtonGroup> — presentational cluster of action <Button>s.
*
* Composes <Group> for the layout (attached/segmented corners + border
* overlap, direction, equal-width stretch) instead of re-implementing
* it, and publishes a context that child <Button>s read for their shared
* visual defaults (variant / size / color / disabled).
*
* scope:['eidos'] — there is no behaviour. The ARIA contract
* (`role="group"` + `aria-orientation` + accessible name) is DECLARED in
* `buttonGroupMorfo`; this wrapper only materialises it. It never stamps
* an attr the morfo doesn't declare.
*/
import { ActiveEidos } from '$uix/eidos';
import Group from '$uix/eidos/components/group';
import type { ButtonGroupProps } from './types';
import { setButtonGroupEidosCtx } from './context';
let {
variant = 'solid',
size = 'md',
color = 'primary',
disabled = false,
orientation = 'horizontal',
attached = true,
block = false,
equal,
children,
...rest
}: ButtonGroupProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// `equal` (match every button to the widest) defaults to ON only for a
// VERTICAL attached group — there it equalises WIDTH to the widest and the
// group stays content-sized. A horizontal group's "equal" fills the
// container (main-axis), which is rarely the wanted default, so it stays OFF
// (natural content widths) unless set explicitly.
const resolvedEqual = $derived(equal ?? (attached && orientation === 'vertical'));
// Shared visual defaults for child <Button>s. Getters keep it reactive:
// when a group prop changes, every child re-reads on its next derivation.
setButtonGroupEidosCtx({
get variant() {
return variant;
},
get size() {
return resolvedSize;
},
get color() {
return color;
},
get disabled() {
return disabled || undefined;
}
});
</script>
<Group
{...rest}
role="group"
direction={orientation === 'vertical' ? 'column' : 'row'}
{attached}
grow={block}
inline={!block}
gap={attached ? 0 : 2}
aria-orientation={orientation}
data-button-group=""
data-orientation={orientation}
data-variant={variant}
data-size={resolvedSize}
data-color={color}
data-block={block ? '' : undefined}
data-equal={resolvedEqual ? '' : undefined}
data-disabled={disabled ? '' : undefined}
>
{@render children?.()}
</Group>

@ -0,0 +1,39 @@
import { getContext, setContext } from 'svelte';
import type { ButtonGroupColor, ButtonGroupSize, ButtonGroupVariant } from './types';
/**
* Eidos context published by <ButtonGroup> and read by child <Button>s
* (and everything built on Button: IconButton, SplitButton, FAB).
*
* It carries the group's shared VISUAL defaults. A child uses a value ONLY
* as a fallback — an explicit prop on the child always wins
* (`child.variant ?? group?.variant ?? 'solid'`). When a Button is used
* outside a group, `getButtonGroupEidosCtx()` returns undefined and the
* child falls back to its own defaults — fully backward-compatible.
*
* Why a context and not CSS descendant selectors: Button's recipe reads
* `data-variant` / `data-size` / `data-color` ON the button element, so a
* descendant selector on the group can't reach them. The child must carry
* those attrs itself — the context lets it inherit the group's values
* while keeping every Button a real, standalone Button (no `Group.Item`).
*
* Property getters keep the bridge reactive: when the group's prop changes
* every child observing the getter re-reads on its next derivation.
*/
const KEY = Symbol('button-group-eidos-ctx');
export interface ButtonGroupEidosCtx {
readonly variant: ButtonGroupVariant | undefined;
readonly size: ButtonGroupSize | undefined;
readonly color: ButtonGroupColor | undefined;
readonly disabled: boolean | undefined;
}
export function setButtonGroupEidosCtx(ctx: ButtonGroupEidosCtx): void {
setContext(KEY, ctx);
}
export function getButtonGroupEidosCtx(): ButtonGroupEidosCtx | undefined {
return getContext<ButtonGroupEidosCtx | undefined>(KEY);
}

@ -0,0 +1,26 @@
// ButtonGroup — eidos presentational cluster of action buttons.
//
// import { ButtonGroup } from '$uix/eidos/components/button-group';
// import { Button } from '$uix/eidos/components/button';
//
// <ButtonGroup aria-label="Text alignment" variant="surface">
// <Button>Left</Button>
// <Button>Center</Button>
// <Button>Right</Button>
// </ButtonGroup>
//
// Single-root component — the default IS the component. Children are real
// <Button> / <IconButton> / <SplitButton> elements; there is no
// `ButtonGroup.Item` (you drop real buttons in, and they inherit the
// group's variant / size / color / disabled via context).
import ButtonGroup from './button-group.svelte';
export { ButtonGroup };
export default ButtonGroup;
export type {
ButtonGroupProps,
ButtonGroupVariant,
ButtonGroupSize,
ButtonGroupColor,
ButtonGroupOrientation
} from './types';

@ -0,0 +1,89 @@
import type { Snippet } from 'svelte';
import type { GroupProps } from '$uix/eidos/components/group';
import type { ButtonSize, ButtonVariant } from '$uix/eidos/components/button';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/** Visual variant propagated to child Buttons. Mirrors {@link ButtonVariant}. */
export type ButtonGroupVariant = ButtonVariant;
/** Sizing scale propagated to child Buttons. Mirrors {@link ButtonSize}. */
export type ButtonGroupSize = ButtonSize;
/** Hierarchical color propagated to child Buttons (non-evaluative). */
export type ButtonGroupColor = 'primary' | 'secondary' | 'neutral';
/** Layout axis of the group. */
export type ButtonGroupOrientation = 'horizontal' | 'vertical';
type ButtonGroupOwnProps = {
/**
* Visual variant propagated as the DEFAULT to every child Button. An
* explicit `variant` on a child Button overrides it. @default 'solid'
*/
variant?: ButtonGroupVariant;
/**
* Sizing scale propagated as the DEFAULT to every child Button. @default 'md'
*/
size?: ResponsiveProp<ButtonGroupSize>;
/**
* Hierarchical color propagated as the DEFAULT to every child Button.
* @default 'primary'
*/
color?: ButtonGroupColor;
/** Disable every child Button at once. @default false */
disabled?: boolean;
/**
* Layout axis. Maps to `aria-orientation` + `data-orientation`.
* @default 'horizontal'
*/
orientation?: ButtonGroupOrientation;
/**
* Render as a single segmented unit — children share edges, inner
* corners squared, doubled borders collapsed. @default true
*/
attached?: boolean;
/**
* Stretch to fill the container inline-size; children share width
* equally. @default false
*/
block?: boolean;
/**
* Make every button the width of the **widest** one (content-sized, not
* container-filling — that's `block`). Defaults to ON when `attached`, so
* a segmented control reads as one unit; set explicitly to force it on or
* off in either layout. @default attached
*/
equal?: boolean;
};
/**
* Props for the eidos `<ButtonGroup>`.
*
* A presentational cluster of independent action buttons — `role="group"`
* plus shared visual defaults that propagate to the child Buttons. The
* children are real `<Button>` / `<IconButton>` / `<SplitButton>` elements
* (there is no `ButtonGroup.Item`). Provide an accessible name via the
* inherited `aria-label` / `aria-labelledby` — `role="group"` needs one.
*
* The accessible name + grouping (`role`, `aria-orientation`, `aria-label`,
* `aria-labelledby`) are declared in `buttonGroupMorfo`; this prop surface
* adds the visual treatment. The component composes `<Group>` for layout, so
* the passthrough surface mirrors `GroupProps` (class / style / id /
* `aria-*` / event handlers …) minus the axes ButtonGroup owns. `role` and
* `aria-orientation` are fixed by the morfo, so they are not passthrough.
*/
export type ButtonGroupProps = ButtonGroupOwnProps &
Omit<
GroupProps,
| keyof ButtonGroupOwnProps
| 'grow'
| 'direction'
| 'inline'
| 'gap'
| 'role'
| 'aria-orientation'
| 'children'
> & {
/** The buttons. */
children?: Snippet;
};

@ -0,0 +1,120 @@
# Fab
A **Floating Action Button** — a prominent, elevated button for a screen's
primary action. Circular icon by default, or an `extended` pill with a label.
It floats over content (own `placement`, fixed) and lifts on hover.
```svelte
<script lang="ts">
import { Fab } from '$uix/eidos/components/fab';
import { SvgPlus } from '$uix/eidos/components/svg';
</script>
<Fab aria-label="New" placement="bottom-end" onclick={create}>
{#snippet icon()}<SvgPlus />{/snippet}
</Fab>
<!-- extended: pill with a visible label -->
<Fab extended aria-label="Compose" onclick={compose}>
{#snippet icon()}<SvgPlus />{/snippet}
Compose
</Fab>
```
## Baseline
No Air baseline (the old `air/layout/float` was a generic 3×3 placement util,
removed in the refactor). FAB is **pure composition** over one existing
component:
- **`<Button>`** — the chrome (variant fill, `intent`/`color`, focus ring, the
icon slot) + the native `<button>` + `onclick`. The FAB renders `iconOnly`
(circular) or with a visible label (extended), `rounded="full"`.
The FAB recipe adds only what Button doesn't: the prominent elevation, the FAB
size scale, the circular/extended shape, and the floating placement.
## Comparativa
| Capability | Material Web | MUI | Ant `FloatButton` | Vuetify `v-fab` | **UIX** |
| --- | --- | --- | --- | --- | --- |
| Circular icon FAB | ✓ | ✓ | ✓ | ✓ | ✓ |
| Extended (icon + label) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Sizes | sm/md/lg | sm/md/lg | default/large | size | sm/md/lg (FAB scale) |
| intent / colour | branded/surface | color | type | color | intent + color (Button) |
| Elevation + hover lift | ✓ | ✓ | ✓ | ✓ | ✓ |
| Owns floating placement | ✗ | ✗ | ✓ | ✓ | ✓ (corners + `static`) |
| Speed-dial / FAB menu | ✗ | ✓ SpeedDial | ✓ `.Group` | — | **deferred** (Gaps) |
WAI-ARIA: a FAB is just a [Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/)
(which `<Button>` implements). The icon-only form needs an `aria-label`.
## Decisiones
- **Composes `<Button>` (`scope:['eidos']`).** Button owns the chrome, intent
palette, focus ring, icon slot and the native button; the FAB recipe layers
the elevation / size / shape / placement on the same `<button>` (it carries
`data-button` + `data-fab`). No primitive reinvented.
- **Own size scale (`sm`/`md`/`lg`), not Button's `xs…xl`.** A FAB is a
deliberately prominent target (≈ 40 / 56 / 72px) — larger than any control,
so it sits off the `--control-height-*` ladder by design. The dimensional
rules out-specify Button's `[data-button][data-size]` by also matching
`[data-fab]`.
- **Owns its floating placement.** "Floating" is the name — a FAB that doesn't
float is just a round Button. `placement` (default `bottom-end`) pins it
`fixed` to a corner with `env(safe-area-inset-*)` padding; `placement="static"`
opts out so the consumer positions it. The four corners only — a centred FAB
needs transform-centring that fights the hover-lift `translate`, and corners
are the canonical FAB positions.
- **Hover lift via `translate`, not `transform`.** Button's active press-squeeze
animates `transform: scale(...)`; the FAB's lift uses the `translate` property
so the two compose instead of clobbering each other (same lesson as IconButton).
- **Elevation is a shadow, not `data-depth`.** The `data-depth` planes set a
*surface background* (they're for panels — dialog / drawer / popover). A FAB
is a coloured button that merely casts a strong shadow, so it drives Button's
own `--button-shadow` token (= `--shadow-overlay`) — composing with Button's
`outline` focus ring — not a depth plane. The shadow scale tops out at
`overlay`, so the hover affordance is the `translate` lift, not a heavier
shadow (keeps it token-only).
- **`z` = the sticky band (`--z-index-sticky`).** Above page content, below
dropdowns / popovers / modals, so menus and dialogs open over the FAB.
## Subset
- **size**: FAB's own `'sm' | 'md' | 'lg'`.
- **variant**: full `ButtonVariant` (default `solid` — the prominent action).
- **placement**: `'bottom-end' | 'bottom-start' | 'top-end' | 'top-start' | 'static'`.
- **intent / color**: forwarded to Button (evaluative tone is per-element, per
the intent doctrine).
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Speed-dial / FAB menu | **diferir** | FAB that fans out to several actions (MUI SpeedDial / Ant `.Group`). A separate feature — staggered expand + a menu surface + backdrop. Build once the base FAB is verified; compose the motion stagger + a menu-like open state. |
| Centred placement (`*-center`) | **descartar** | Needs transform-centring that conflicts with the hover-lift `translate`. Corners are the canonical FAB positions; use `placement="static"` + your own layout for a centred FAB. |
| Badge | **descartar** | Compose `<Badge>` over the FAB; not a FAB concern. |
| Scroll-to-top (Ant BackTop) | **descartar** | A distinct scroll widget, out of scope for an action button. |
## Audit exceptions
Like `icon` / `button-group` / `split-button` (the eidos-only precedent),
`component:audit` reports two `error` rows that are **audit limitations for
`scope:['eidos']` components**, not defects:
- **`E-2.2`** — the recipe is code-split (`fab.svelte` self-imports `./fab.css`),
so it is intentionally NOT `@import`ed in `index.css` (which would double-load it).
- **`D-3.1`** — eidos-only, so the demo ships only the `eidosSnippet` (there is
no soma layer for a `somaSnippet`).
## Sema events
0 events on Fab's own morfo. It is interactive **by composition**: the press
fires `contact-activate` (Button) — visible in the demo trace strip.
## Passive justification
Fab's morfo declares 0 sema events because the component is a *composition
frame* — it owns no perceptual moment of its own. Per CANON §5 the signal
belongs to the act inside the frame (the Button press), not the frame. Fab only
contributes the `data-fab` identity + the visual treatment.

@ -0,0 +1,131 @@
/*
* Fab (Floating Action Button) recipe.
*
* The composed <Button> paints the chrome (variant fill, intent colour, focus
* ring, icon slot). This recipe owns only the FAB-specific treatment, layered
* on the same `<button>` (it carries `data-button` + `data-fab`):
*
* 1. prominent elevation, lifting on hover;
* 2. the FAB size scale (its own sm/md/lg — larger than a control);
* 3. the circular icon form vs the extended pill (`data-extended`);
* 4. floating placement, fixed to the viewport (`data-placement`).
*
* The dimensional rules out-specify Button's `[data-button][data-size]` (0,2,0)
* by also matching `[data-fab]` (≥ 0,3,0), so they win regardless of which
* code-split chunk loads first.
*
* Elevation drives Button's OWN `--button-shadow` token (its `[data-button]`
* rule already renders + transitions `box-shadow`), so the FAB shadow composes
* with Button's `outline` focus ring instead of fighting its box-shadow rule on
* a specificity tie. The `[data-button][data-fab]` prefix (0,2,0) wins over any
* `[data-button]` setter regardless of code-split load order.
*
* The hover lift uses the `translate` property, NOT `transform` — Button's
* active press-squeeze animates `transform: scale(...)`, so a transform here
* would clobber it; `translate` composes cleanly alongside it. Button transitions
* `transform` but not `translate`, so we re-declare its transition list + add
* `translate` for the lift to animate.
*/
/* The FAB's size / icon / offset / elevation / lift scale (`--fab-*`) is declared
* in the recipe (`base.ts > fab`) and generated to `:root` — this file only
* CONSUMES it (the values are NOT literals here). The scale is deliberately off
* the control-height ladder (Material 40 / 56 / 72); the shadow tops out at
* `--shadow-overlay`, so the hover affordance is the `--fab-lift` translate. */
[data-button][data-fab] {
--button-shadow: var(--fab-shadow);
transition:
background var(--button-transition-duration) var(--button-transition-ease),
border-color var(--button-transition-duration) var(--button-transition-ease),
color var(--button-transition-duration) var(--button-transition-ease),
box-shadow var(--button-transition-duration) var(--button-transition-ease),
transform var(--button-transition-duration) var(--button-transition-ease),
translate var(--button-transition-duration) var(--button-transition-ease);
}
[data-button][data-fab]:hover:not([data-disabled]):not(:active) {
translate: 0 calc(-1 * var(--fab-lift));
}
[data-button][data-fab][data-disabled] {
--button-shadow: none;
}
/* ── Size (diameter + glyph) ─────────────────────────────────────────── */
[data-button][data-fab][data-fab-size='sm'] {
block-size: var(--fab-size-sm);
font-size: var(--fab-icon-sm);
}
[data-button][data-fab][data-fab-size='md'] {
block-size: var(--fab-size-md);
font-size: var(--fab-icon-md);
}
[data-button][data-fab][data-fab-size='lg'] {
block-size: var(--fab-size-lg);
font-size: var(--fab-icon-lg);
}
/* Circular icon-only form: width = height. Overrides Button's iconOnly
* `inline-size: var(--_button-height)` (which would size to the control
* height, not the FAB diameter). */
[data-button][data-fab][data-fab-size='sm']:not([data-extended]) {
inline-size: var(--fab-size-sm);
}
[data-button][data-fab][data-fab-size='md']:not([data-extended]) {
inline-size: var(--fab-size-md);
}
[data-button][data-fab][data-fab-size='lg']:not([data-extended]) {
inline-size: var(--fab-size-lg);
}
/* Extended pill: height = diameter, width hugs the content with comfortable
* inline padding + a gap between the icon and the label. */
[data-button][data-fab][data-extended] {
inline-size: auto;
padding-inline: var(--space-5);
gap: var(--space-2);
}
/* The visible glyph follows the FAB's font-size (1em icon). */
[data-fab] [data-button-icon] {
font-size: 1em;
}
/* ── Floating placement (fixed to the viewport) ──────────────────────── */
/*
* `static` = no positioning (the consumer places the FAB). The four corners
* pin to their edges, padded by `--fab-offset` + the matching safe-area inset
* so the FAB clears notches / home indicators.
*/
[data-fab][data-placement='bottom-end'],
[data-fab][data-placement='bottom-start'],
[data-fab][data-placement='top-end'],
[data-fab][data-placement='top-start'] {
position: fixed;
z-index: var(--fab-z);
}
[data-fab][data-placement='bottom-end'] {
inset-block-end: calc(var(--fab-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-end: calc(var(--fab-offset) + env(safe-area-inset-right, 0px));
}
[data-fab][data-placement='bottom-start'] {
inset-block-end: calc(var(--fab-offset) + env(safe-area-inset-bottom, 0px));
inset-inline-start: calc(var(--fab-offset) + env(safe-area-inset-left, 0px));
}
[data-fab][data-placement='top-end'] {
inset-block-start: calc(var(--fab-offset) + env(safe-area-inset-top, 0px));
inset-inline-end: calc(var(--fab-offset) + env(safe-area-inset-right, 0px));
}
[data-fab][data-placement='top-start'] {
inset-block-start: calc(var(--fab-offset) + env(safe-area-inset-top, 0px));
inset-inline-start: calc(var(--fab-offset) + env(safe-area-inset-left, 0px));
}
@media (prefers-reduced-motion: reduce) {
[data-button][data-fab]:hover:not([data-disabled]):not(:active) {
translate: none;
}
}

@ -0,0 +1,57 @@
<script lang="ts">
import './fab.css';
/**
* Eidos `<Fab>` — Floating Action Button.
*
* Composes the framework `<Button>` and layers on the FAB treatment. The
* Button owns the chrome (variant fill, `intent`/`color`, focus ring, the
* icon slot, the native `<button>` + `onclick`); the FAB recipe owns the
* elevation (rest → hover lift), the circular / extended shape, the FAB size
* scale, and the floating placement.
*
* scope:['eidos'] — no runtime. The morfo (`fabMorfo`) declares the action
* role + accessible name + the `data-fab` identity; this wrapper materialises
* them on the composed Button and stamps the eidos-only visual knobs
* (`data-fab-size` / `data-extended` / `data-placement`). It never stamps an
* attr the morfo doesn't promise as the FAB's own (role / disabled come from
* Button).
*/
import { ActiveEidos } from '$uix/eidos';
import { Button } from '$uix/eidos/components/button';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import type { FabProps } from './types';
let {
extended = false,
size = 'md',
placement = 'bottom-end',
offset,
variant = 'solid',
icon,
children,
style,
...rest
}: FabProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedPlacement = $derived(eidos.resolve(placement, 'bottom-end'));
const composedStyle = $derived(
composeInlineStyle(offset ? `--fab-offset: ${offset};` : undefined, style)
);
</script>
<Button
{...rest}
{variant}
{icon}
rounded="full"
iconOnly={!extended}
style={composedStyle}
data-fab=""
data-fab-size={resolvedSize}
data-extended={extended ? '' : undefined}
data-placement={resolvedPlacement}
>
{@render children?.()}
</Button>

@ -0,0 +1,23 @@
// Fab — eidos Floating Action Button.
//
// import { Fab } from '$uix/eidos/components/fab';
//
// <Fab aria-label="New" placement="bottom-end" onclick={create}>
// {#snippet icon()}<SvgPlus />{/snippet}
// </Fab>
//
// <!-- extended: pill with a visible label -->
// <Fab extended aria-label="Compose" onclick={compose}>
// {#snippet icon()}<SvgPlus />{/snippet}
// Compose
// </Fab>
//
// Composes <Button> (chrome / intent / focus / icon slot / native button) and
// adds the FAB elevation, the circular / extended shape, the FAB size scale,
// and the floating placement. scope:['eidos'] — see `fabMorfo`.
import Fab from './fab.svelte';
export { Fab };
export default Fab;
export type { FabProps, FabSize, FabPlacement } from './types';

@ -0,0 +1,58 @@
import type { Snippet } from 'svelte';
import type { ButtonProps } from '$uix/eidos/components/button';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/**
* FAB size scale. Deliberately its OWN 3-step scale (not Button's `xs…xl`): a
* FAB is a prominent target, larger than an inline control — `sm` ≈ 40px, `md`
* ≈ 56px (the canonical FAB), `lg` ≈ 72px.
*/
export type FabSize = 'sm' | 'md' | 'lg';
/**
* Floating placement around the viewport. `static` opts out of the fixed
* positioning entirely — the FAB becomes a prominent, elevated button the
* consumer positions itself (in a toolbar, a card corner, …).
*/
export type FabPlacement = 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start' | 'static';
/**
* Props for the eidos `<Fab>` — Floating Action Button.
*
* Composes `<Button>` for the chrome (variant fill, `intent`/`color`, focus
* ring, the icon slot, the native `<button>` + `onclick`) and layers on the
* FAB treatment: prominent elevation (rest → hover lift), the circular (or
* extended-pill) shape, the FAB size scale, and the floating placement.
*
* The accessible name + action role + structural identity (`data-fab`) are
* declared in `fabMorfo`; this prop surface adds the visual + placement knobs.
* Button's own `size` / `iconOnly` / `rounded` / `block` are owned by the FAB
* (size → FAB scale, iconOnly → derived from `extended`, shape is fixed), so
* they are omitted from the passthrough.
*/
export type FabProps = Omit<
ButtonProps,
'size' | 'iconOnly' | 'rounded' | 'block' | 'children'
> & {
/** The label. Visible in the extended pill; sr-only (named via `aria-label`) on the icon-only FAB. */
children?: Snippet;
/**
* Extended FAB — a pill that shows the label next to the icon. Off → a
* circular icon-only FAB (the label stays in the accessibility tree).
* @default false
*/
extended?: boolean;
/** FAB size scale. @default 'md' */
size?: ResponsiveProp<FabSize>;
/**
* Where the FAB floats, fixed to the viewport (with safe-area insets).
* `static` opts out — no fixed positioning, the consumer places it.
* @default 'bottom-end'
*/
placement?: ResponsiveProp<FabPlacement>;
/**
* Distance from the viewport edges when floating (any CSS length). Ignored
* for `placement="static"`. @default var(--fab-offset) (≈ space-5)
*/
offset?: string;
};

@ -0,0 +1,111 @@
# OnionMenu
A trigger-agnostic **radial ("onion") menu**: any trigger opens a ring of options
around a centre; an option with children drills into a new concentric ring. The
active branch owns a distinct theme colour, the deeper rings derive lighter/darker
tints by depth, and the already-passed inner rings mute to a trail — only the
followed option stays lit.
```svelte
<script>
import { OnionMenu, OnionLayer, OnionOption } from '$uix/eidos/components/onion-menu';
import { SvgPlus, SvgCheck } from '$uix/eidos/components/svg';
let open = $state(false);
</script>
<OnionMenu bind:open placement="bottom-end">
<OnionLayer textDisposition="angular">
<OnionOption label="Create" color="teal">
{#snippet icon()}<SvgPlus />{/snippet}
<OnionLayer textDisposition="vertical">
<OnionOption label="Document" onSelect={createDoc} />
<OnionOption label="Folder" onSelect={createFolder} />
</OnionLayer>
</OnionOption>
<OnionOption label="Share" onSelect={share}>
{#snippet icon()}<SvgCheck />{/snippet}
</OnionOption>
</OnionLayer>
</OnionMenu>
```
## Compositional API
The menu is **declared by composition**, not a `tree` data prop. `<OnionLayer>` is a
ring; an option's children are a nested `<OnionLayer>` placed inside its
`<OnionOption>` (like `DropdownMenu.Sub`). The parts don't paint — they **register**
into a reactive node tree via context; the root `<OnionMenu>` reads that tree and
paints the SVG rings centrally. Parts declare structure, the root draws.
| Component | Role |
| --- | --- |
| `<OnionMenu>` | Root. Owns `open` / `drillPath` / `selected`, the trigger, render + behaviour. |
| `<OnionLayer>` | One ring. Root layer = ring 1; a layer inside an option = that option's children. |
| `<OnionOption>` | One sector. Nest an `<OnionLayer>` inside it to give it children. |
Key `<OnionMenu>` props: `placement` (anchor → arc span 90/180/360 + orientation),
`open` / `drillPath` / `selected` (bindable), `innerRadius` / `ringWidth` / `gap` /
`fontSize` / `width` / `height` (geometry), `trigger` (snippet — any element).
`<OnionLayer>`: `textDisposition` (`angular`/`horizontal`/`vertical`), `color`,
`display`, `ringWidth`, `gap`. `<OnionOption>`: `label`, `icon` (snippet), `color`,
`display`, `disabled`, `onSelect`.
## Layers
- **morfo** (`$uix/morfo/components/onion-menu`) — the contract: a Menu Button (APG),
`role="menu"` surface, `menuitem` sectors, the `focus` policy (non-modal: arrows
navigate, Tab / outside-click exit + close, focus returns to the trigger), the
keyboard map, and the `open` / `close` (emerge) + `commit-select` (commit · affirm)
events. `expression: 'family-default'` — no per-component sema pack.
- **soma** (`$soma/components/onion-menu`) — the headless behaviour bridge:
`createOnionMenuRuntime` emits the perceptual signals and dispatches the keyboard
contract; `onionNav*` are the pure radial focus maths. The root drives focus / drill
/ dismiss against them.
- **eidos** (here) — the SVG render + the two pure engines (geometry + colour) + the
recipe. The root owns `open` / `drill` state and paints.
- **sema** — picks `open`/`close` up from the `emerge` family base and `commit-select`
from `commit` (no pack; family defaults).
### Pure engines
- **`geometry.ts`** — annular-sector maths: placement → arc, constant-pixel gaps (the
angular pad scales `1/r`), curved labels via `<textPath>` (flipped on the lower
arc), icon boxes. Output is geometry only — no colour, no DOM.
- **`color.ts`** — fully **theme-driven** colour via `color-mix()` of the theme's
canonical scales (`--scale-{name}-*`) / roles (`--primitive-{role}-*`). A branch is a
scale name; depth + muted recede the solid (step 9) toward the subtle step (step 3),
which tracks the **mode background** (lightens in light mode, darkens in dark mode).
Text auto-contrasts (`--color-content-on-solid` over solids, step 12 over tints).
No invented colours — retinting the theme retints the menu.
## Keyboard & a11y
Opened from the trigger (`Enter` / `Space` / `ArrowDown`), focus moves into the ring.
Within a ring: `ArrowRight` / `ArrowLeft` move (wrap around, skip disabled),
`Enter` / `Space` activate (drill a branch / choose a leaf), `Backspace` drills up (and
closes at the root), `Escape` closes. Closing restores focus to the trigger.
Outside-click and Tab-out dismiss (non-modal). Branch sectors expose
`aria-haspopup="menu"` + `aria-expanded`; the surface is a `role="menu"`, sectors are
`role="menuitem"`.
## Recipe
The fills / text / stroke are computed per sector by the colour engine and applied
inline (they depend on the live branch + drill state). The recipe (`onion-menu.css`)
owns only structural chrome: the surface, the sector enter animation, the
hover / selection rings (`--color-content-on-solid` at full / `--opacity-65`), the
elevation (`--shadow-raised` sectors, `--shadow-overlay` trigger — single-layer tokens
drop straight into `drop-shadow()`), the label / icon typography, and the `+`→`×`
trigger glyph. All colours come through tokens; no raw hex in the recipe.
## Decisions
- **Compositional, not data-driven** — parts register a node tree; the root paints. An
option's children nest *inside* the option (`DropdownMenu.Sub` shape).
- **Theme-driven colour** — `color-mix` of `--scale-*`, mode-adaptive recession. The
`color` prop is a theme scale / role name, never a raw colour.
- **Behaviour lives in the SVG root, not a parts provider** — the menu is a single SVG
renderer that already owns its state, so soma here is a behaviour bridge (runtime +
nav maths) the root drives, rather than a compound provider tree.
- **Focus on open transition, not mount** — a controlled `open=true` at mount does not
steal focus; user/programmatic open transitions move focus into the ring.

@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import {
DEFAULT_ONION_SCALES,
onionBaseColor,
deriveOnionShade,
deriveOnionMuted,
deriveOnionTrigger
} from './color';
// The colour engine is theme-driven: it emits CSS colour STRINGS that reference
// the theme's `--scale-*` / `--primitive-*` tokens (resolved live by the cascade).
// These tests assert the token wiring, not concrete hex values.
const ON_SOLID = 'var(--color-content-on-solid)';
/** The `pct` of the second colour in `color-mix(in oklch, A, B pct%)`, or 0 if no mix. */
const mixPct = (s: string): number => {
const m = s.match(/\s(\d+(?:\.\d+)?)%\)\s*$/);
return m ? Number(m[1]) : 0;
};
describe('onionBaseColor — distinct theme scale per top option', () => {
it('cycles the default scales by index', () => {
expect(onionBaseColor(0)).toBe(DEFAULT_ONION_SCALES[0]);
expect(onionBaseColor(1)).toBe(DEFAULT_ONION_SCALES[1]);
expect(onionBaseColor(DEFAULT_ONION_SCALES.length)).toBe(DEFAULT_ONION_SCALES[0]);
});
it('the default scales are distinct canonical scale names', () => {
expect(new Set(DEFAULT_ONION_SCALES).size).toBe(DEFAULT_ONION_SCALES.length);
});
it('honours an explicit scale / role name', () => {
expect(onionBaseColor(0, 'indigo')).toBe('indigo');
expect(onionBaseColor(3, 'primary')).toBe('primary');
});
});
describe('deriveOnionShade — derive deeper rings from the branch scale', () => {
it('references the branch scale tokens (no hardcoded colours)', () => {
const s = deriveOnionShade('teal', 2);
expect(s.fill).toContain('var(--scale-teal-9)');
expect(s.fill).toContain('var(--scale-teal-3)');
expect(s.fill.startsWith('color-mix(in oklch')).toBe(true);
});
it('depth 1 is the solid step (no recession)', () => {
expect(deriveOnionShade('teal', 1).fill).toBe('var(--scale-teal-9)');
});
it('recedes further toward the subtle step with depth', () => {
const p1 = mixPct(deriveOnionShade('teal', 1).fill);
const p2 = mixPct(deriveOnionShade('teal', 2).fill);
const p3 = mixPct(deriveOnionShade('teal', 3).fill);
expect(p2).toBeGreaterThan(p1);
expect(p3).toBeGreaterThan(p2);
});
it('maps a role name to the primitive token', () => {
expect(deriveOnionShade('primary', 2).fill).toContain('var(--primitive-primary-9)');
});
it('text is on-solid over the solid steps and the scale text step over the receded tints', () => {
expect(deriveOnionShade('teal', 1).text).toBe(ON_SOLID);
expect(deriveOnionShade('teal', 6).text).toBe('var(--scale-teal-12)');
});
});
describe('deriveOnionMuted', () => {
it('is a heavily receded tint of the scale with high-contrast text', () => {
const m = deriveOnionMuted('orange');
expect(m.fill).toContain('var(--scale-orange-9)');
expect(m.fill).toContain('var(--scale-orange-3)');
expect(mixPct(m.fill)).toBeGreaterThan(mixPct(deriveOnionShade('orange', 3).fill));
expect(m.text).toBe('var(--scale-orange-12)');
});
});
describe('deriveOnionTrigger', () => {
it('is the neutral solid at root and the branch solid when drilled', () => {
expect(deriveOnionTrigger()).toBe('var(--primitive-neutral-9)');
expect(deriveOnionTrigger('indigo')).toBe('var(--scale-indigo-9)');
});
});

@ -0,0 +1,107 @@
/**
* OnionMenu colour derivation — fully THEME-DRIVEN, via `color-mix()` of the
* theme's canonical scales (`--scale-{name}-*`) and roles (`--primitive-{role}-*`).
* No invented OKLCH, no hardcoded colours: retinting the theme retints the menu,
* and light / dark is handled by the scales themselves.
*
* The design's colour rule, expressed in theme steps:
* - every TOP-LEVEL option carries a distinct branch colour (a theme SCALE);
* - drilling derives the deeper rings by mixing the branch's solid (step 9)
* toward its subtle step (step 3) — which RECEDES toward the mode background,
* so the tint lightens in light mode and darkens in dark mode (correct in both);
* - already-drilled inner rings are shown muted (heavily receded), only the
* followed option stays lit;
* - text auto-contrasts: `--color-content-on-solid` over the vivid solids, the
* scale's high-contrast text (step 12, itself mode-adaptive) over the tints.
*
* The functions return CSS colour STRINGS that reference theme tokens, applied
* inline by the render (`style="fill: …"`) so the cascade resolves them live.
*/
/** Canonical colourful scales for distinct default branches (no semantic load). */
export const DEFAULT_ONION_SCALES = [
'purple',
'teal',
'orange',
'indigo',
'plum',
'green',
'red'
] as const;
/** Theme roles (vs raw scales) so an option may name a role like `primary`. */
const ROLE_NAMES = new Set([
'primary',
'secondary',
'tertiary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
]);
/** `var()` reference to a theme colour token step. Roles → primitive, else scale. */
function token(name: string, step: number | string): string {
return `var(--${ROLE_NAMES.has(name) ? `primitive-${name}` : `scale-${name}`}-${step})`;
}
/**
* Mix the branch solid (step 9) toward its subtle step (step 3) by `pct`. Step 3
* tracks the mode background, so this recedes (lightens light-mode / darkens
* dark-mode) rather than always lightening toward a hardcoded white.
*/
function recede(name: string, pct: number): string {
if (pct <= 0) return token(name, 9);
return `color-mix(in oklch, ${token(name, 9)}, ${token(name, 3)} ${pct}%)`;
}
/** The branch colour for a top-level option: an explicit name, else a distinct default by index. */
export function onionBaseColor(index: number, explicit?: string): string {
return explicit ?? DEFAULT_ONION_SCALES[index % DEFAULT_ONION_SCALES.length];
}
export interface OnionShade {
/** Sector fill — a theme-token CSS colour. */
readonly fill: string;
/** Contrasting label / icon colour — a theme-token CSS colour. */
readonly text: string;
}
/** Recession per depth step, capped so the deepest visible ring stays legibly coloured. */
const DEPTH_RECEDE_PCT = 20;
const DEPTH_RECEDE_MAX = 55;
/** At/under this recession the fill is solid enough for on-solid text; past it, step-12 wins. */
const ON_SOLID_MAX_PCT = 30;
/**
* Fill + text for a branch at `depth` (1-based: 1 = the branch's solid, deeper =
* progressively receded toward the mode background).
*/
export function deriveOnionShade(name: string, depth: number): OnionShade {
const pct = Math.min(Math.max(0, depth - 1) * DEPTH_RECEDE_PCT, DEPTH_RECEDE_MAX);
return {
fill: recede(name, pct),
text: pct <= ON_SOLID_MAX_PCT ? 'var(--color-content-on-solid)' : token(name, 12)
};
}
/** Recession for the muted faded-trail (already-drilled, non-followed) sectors. */
const MUTED_RECEDE_PCT = 80;
/** The muted (faded-trail) shade for an already-drilled, non-followed option. */
export function deriveOnionMuted(name: string): OnionShade {
return { fill: recede(name, MUTED_RECEDE_PCT), text: token(name, 12) };
}
/**
* The trigger fill: the active branch's solid (step 9), or the neutral role at
* root. Step 9 is the canonical solid — mid-lightness in BOTH modes, so the
* on-solid (white) glyph always reads. (A "deeper" tone has no mode-consistent
* scale step: 10–12 lighten in dark mode and would kill the glyph contrast.)
* Prominence comes from the elevation shadow + the `+` / `×` glyph, not a darker fill.
*/
export function deriveOnionTrigger(name?: string): string {
return token(name ?? 'neutral', 9);
}

@ -0,0 +1,96 @@
import { getContext, setContext } from 'svelte';
import type { Snippet } from 'svelte';
import type { OnionDisposition, OnionDisplay } from './geometry';
import type { OnionColor } from './types';
/**
* OnionMenu registration tree. The composed `<OnionLayer>` / `<OnionOption>`
* components don't paint — they REGISTER into this reactive tree (via the
* contexts below); the root `<OnionMenu>` reads the tree and renders the SVG
* centrally. Parts declare structure, the root draws. Both node types are
* reactive ($state) so changing a part's props re-paints the menu.
*/
export class OnionOptionNode {
label = $state<string | undefined>(undefined);
icon = $state<Snippet | undefined>(undefined);
color = $state<OnionColor | undefined>(undefined);
display = $state<OnionDisplay | undefined>(undefined);
disabled = $state(false);
onSelect = $state<(() => void) | undefined>(undefined);
// `$state.raw`: the child layer is a class instance — keep it UN-proxied so its
// identity is stable (otherwise it gets deep-proxied and indexOf / === break).
// Reactivity is reassignment-level, which is exactly the structural granularity.
/** Set by a nested `<OnionLayer>` that composes inside this option. */
childLayer = $state.raw<OnionLayerNode | null>(null);
get hasChildren(): boolean {
return !!this.childLayer && this.childLayer.options.length > 0;
}
}
export class OnionLayerNode {
textDisposition = $state<OnionDisposition | undefined>(undefined);
color = $state<OnionColor | undefined>(undefined);
display = $state<OnionDisplay | undefined>(undefined);
ringWidth = $state<number | undefined>(undefined);
gap = $state<number | undefined>(undefined);
// `$state.raw` + reassign-on-change: option nodes stay UN-proxied (stable
// identity for indexOf / keyed rendering); the array reference changes drive
// the structural reactivity. Each node carries its own field-level $state.
/** In declaration order. */
options = $state.raw<OnionOptionNode[]>([]);
private order: number[] = [];
/** Insert an option at its declaration index (uses the DOM-order seq the layer assigns). */
insert(node: OnionOptionNode, seq: number): void {
// keep `options` ordered by registration sequence so markup order is preserved
let at = this.order.findIndex((s) => s > seq);
if (at === -1) at = this.order.length;
this.order.splice(at, 0, seq);
this.options = [...this.options.slice(0, at), node, ...this.options.slice(at)];
}
remove(node: OnionOptionNode): void {
const i = this.options.indexOf(node);
if (i === -1) return;
this.order.splice(i, 1);
this.options = [...this.options.slice(0, i), ...this.options.slice(i + 1)];
}
}
// ── Contexts ────────────────────────────────────────────────────────────────
const MENU = Symbol('uix.onion-menu');
const LAYER = Symbol('uix.onion-menu.layer');
const OPTION = Symbol('uix.onion-menu.option');
export interface OnionMenuRegistry {
/** The top `<OnionLayer>` registers itself here (null on unmount). */
setRootLayer(node: OnionLayerNode | null): void;
/** Monotonic sequence for declaration-order registration. */
nextSeq(): number;
}
export interface OnionLayerRegistry {
/** Each `<OnionOption>` registers here in declaration order. */
registerOption(node: OnionOptionNode, seq: number): void;
unregisterOption(node: OnionOptionNode): void;
}
export interface OnionOptionRegistry {
/** A nested `<OnionLayer>` registers as this option's children. */
setChildLayer(node: OnionLayerNode | null): void;
}
export const setOnionMenuRegistry = (r: OnionMenuRegistry): void => {
setContext(MENU, r);
};
export const getOnionMenuRegistry = (): OnionMenuRegistry | undefined => getContext(MENU);
export const setOnionLayerRegistry = (r: OnionLayerRegistry): void => {
setContext(LAYER, r);
};
export const getOnionLayerRegistry = (): OnionLayerRegistry | undefined => getContext(LAYER);
export const setOnionOptionRegistry = (r: OnionOptionRegistry): void => {
setContext(OPTION, r);
};
export const getOnionOptionRegistry = (): OnionOptionRegistry | undefined => getContext(OPTION);

@ -0,0 +1,163 @@
import { describe, it, expect } from 'vitest';
import {
onionArc,
polar,
gapPadDeg,
computeOnionGeometry,
ICON_SIZE,
type OnionGeometryInput
} from './geometry';
describe('onionArc — placement drives span + orientation', () => {
it('centre is a full circle', () => {
expect(onionArc('center')).toEqual({ start: 0, span: 360 });
});
it('edges are half circles opening away from the edge', () => {
expect(onionArc('bottom-center')).toEqual({ start: 270, span: 180 });
expect(onionArc('top-center')).toEqual({ start: 90, span: 180 });
expect(onionArc('left-center')).toEqual({ start: 0, span: 180 });
expect(onionArc('right-center')).toEqual({ start: 180, span: 180 });
});
it('corners are quarter circles into the diagonal free quadrant', () => {
for (const p of ['bottom-end', 'bottom-start', 'top-end', 'top-start'] as const) {
expect(onionArc(p).span).toBe(90);
}
expect(onionArc('bottom-end').start).toBe(270); // up-left
expect(onionArc('top-start').start).toBe(90); // down-right
});
});
describe('polar', () => {
it('0° is straight up from the centre', () => {
const [x, y] = polar(100, 100, 50, 0);
expect(x).toBeCloseTo(100, 6);
expect(y).toBeCloseTo(50, 6);
});
it('90° is to the right, 180° is down', () => {
expect(polar(0, 0, 10, 90)[0]).toBeCloseTo(10, 6);
expect(polar(0, 0, 10, 180)[1]).toBeCloseTo(10, 6);
});
});
describe('gapPadDeg — constant pixel gap', () => {
it('scales as 1/r so the px gap is constant across radii', () => {
const inner = gapPadDeg(50, 6);
const outer = gapPadDeg(100, 6);
// double the radius → half the angular pad → same arc length in px
expect(outer).toBeCloseTo(inner / 2, 6);
// arc length (= angle_rad * r) is identical at both radii
expect((inner / (180 / Math.PI)) * 50).toBeCloseTo((outer / (180 / Math.PI)) * 100, 6);
});
});
const baseInput = (over: Partial<OnionGeometryInput> = {}): OnionGeometryInput => ({
placement: 'center',
center: [200, 200],
innerRadius: 40,
rings: [
{
options: [
{ hasIcon: true, display: 'both' },
{ hasIcon: true, display: 'both' },
{ hasIcon: false, display: 'both' },
{ hasIcon: true, display: 'both' }
],
width: 60,
gap: 5,
disposition: 'angular'
}
],
...over
});
describe('computeOnionGeometry', () => {
it('emits one sector per option and partitions the arc evenly', () => {
const g = computeOnionGeometry(baseInput());
expect(g.sectors).toHaveLength(4);
expect(g.span).toBe(360);
// each sector spans 90° and they tile [0,360)
g.sectors.forEach((s, i) => {
expect(s.a0).toBeCloseTo(90 * i, 6);
expect(s.a1).toBeCloseTo(90 * (i + 1), 6);
expect(s.mid).toBeCloseTo(90 * i + 45, 6);
});
});
it('places rings outward with the configured width; outerRadius accounts for all', () => {
const g = computeOnionGeometry(
baseInput({
rings: [
{ options: [{ hasIcon: false, display: 'text' }], width: 60, gap: 4, disposition: 'angular' },
{ options: [{ hasIcon: false, display: 'text' }], width: 50, gap: 4, disposition: 'angular' }
]
})
);
expect(g.outerRadius).toBe(40 + 60 + 50);
const [r0, r1] = g.sectors;
expect(r0.ring).toBe(0);
expect(r1.ring).toBe(1);
expect(r1.rIn).toBeGreaterThan(r0.rOut); // outer ring is further out
});
it('every sector has a non-empty path and a sane radial band', () => {
for (const s of computeOnionGeometry(baseInput()).sectors) {
expect(s.path.startsWith('M')).toBe(true);
expect(s.path).toContain('A');
expect(s.rOut).toBeGreaterThan(s.rIn);
expect(s.midR).toBeCloseTo((s.rIn + s.rOut) / 2, 6);
}
});
it('display "both" + hasIcon pairs an icon with a label; "icon"-only option still gets a label fallback', () => {
const g = computeOnionGeometry(baseInput());
const withIcon = g.sectors[0]; // hasIcon: true
expect(withIcon.icon).toBeDefined();
expect(withIcon.icon?.size).toBe(ICON_SIZE);
expect(withIcon.label).toBeDefined(); // both → icon AND text
const noIcon = g.sectors[2]; // hasIcon: false, display 'both' → text only
expect(noIcon.icon).toBeUndefined();
expect(noIcon.label).toBeDefined();
});
it('disposition selects the label geometry fields', () => {
const ang = computeOnionGeometry(baseInput()).sectors[0].label!;
expect(ang.disposition).toBe('angular');
expect(ang.arcPath).toBeDefined();
expect(typeof ang.flip).toBe('boolean');
const horiz = computeOnionGeometry(
baseInput({
rings: [
{ options: [{ hasIcon: false, display: 'text' }], width: 60, gap: 5, disposition: 'horizontal' }
]
})
).sectors[0].label!;
expect(horiz.disposition).toBe('horizontal');
expect(horiz.x).toBeDefined();
expect(horiz.arcPath).toBeUndefined();
const vert = computeOnionGeometry(
baseInput({
rings: [
{ options: [{ hasIcon: false, display: 'text' }], width: 60, gap: 5, disposition: 'vertical' }
]
})
).sectors[0].label!;
expect(vert.disposition).toBe('vertical');
expect(typeof vert.rotate).toBe('number');
expect(vert.availablePx).toBeCloseTo(60 - 5, 1); // radial band ≈ width - gap
});
it('display "icon" hides the label when the option has an icon', () => {
const g = computeOnionGeometry(
baseInput({
rings: [
{ options: [{ hasIcon: true, display: 'icon' }], width: 60, gap: 5, disposition: 'angular' }
]
})
);
expect(g.sectors[0].icon).toBeDefined();
expect(g.sectors[0].label).toBeUndefined();
});
});

@ -0,0 +1,283 @@
/**
* OnionMenu geometry engine — pure, DOM-free, deterministic.
*
* Computes the concentric-ring ("onion") radial layout: given the anchor
* placement, the visible levels (root + the active drill path) and their
* per-level config, it returns the annular-sector path for every option plus
* its icon position and label slot. The Svelte layer feeds counts + flags here
* and renders the result; it never does trig itself.
*
* Two invariants from the design:
* - The arc SPAN is a function of placement — 360° at the centre, 180° on an
* edge, 90° in a corner — and the START orients the fan into the free space.
* - The separation between segments is a CONSTANT PIXEL width at every radius:
* the angular padding is derived per-radius (`pad = (gap/2)/r`), so the gap
* never widens outward, and a matching radial gap separates the rings.
*/
export type OnionPlacement =
| 'bottom-end'
| 'bottom-start'
| 'top-end'
| 'top-start'
| 'bottom-center'
| 'top-center'
| 'left-center'
| 'right-center'
| 'center';
/** How an option's label is laid out inside its sector. */
export type OnionDisposition = 'angular' | 'horizontal' | 'vertical';
/** What an option renders. */
export type OnionDisplay = 'icon' | 'text' | 'both';
/** Icon glyph box size + how far it sits off the band centre when paired with text. */
export const ICON_SIZE = 22;
export const ICON_OFFSET = 11;
const DEG = 180 / Math.PI;
const round = (n: number) => Math.round(n * 10) / 10;
/**
* Placement → arc. `span` is the total degrees the ring covers; `start` is the
* opening angle (0° = up, clockwise) chosen so the fan points into the free
* space. Degrees are in the same frame as {@link polar} (0 at top).
*/
export function onionArc(placement: OnionPlacement): { start: number; span: number } {
switch (placement) {
case 'center':
return { start: 0, span: 360 };
case 'bottom-center':
return { start: 270, span: 180 }; // opens up
case 'top-center':
return { start: 90, span: 180 }; // opens down
case 'left-center':
return { start: 0, span: 180 }; // opens right
case 'right-center':
return { start: 180, span: 180 }; // opens left
case 'bottom-end':
return { start: 270, span: 90 }; // up-left
case 'bottom-start':
return { start: 0, span: 90 }; // up-right
case 'top-end':
return { start: 180, span: 90 }; // down-left
case 'top-start':
return { start: 90, span: 90 }; // down-right
}
}
/** Point on a circle. `deg` measured from the top (0°), clockwise. */
export function polar(cx: number, cy: number, r: number, deg: number): [number, number] {
const a = (deg - 90) / DEG;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}
/**
* Angular padding (degrees) that yields a `gap`/2 ARC of constant pixel length
* at radius `r`. Because it scales as 1/r, the gap stays the same width in px at
* the inner and outer radii (the signature of a fixed-pixel gap, not angular).
*/
export function gapPadDeg(r: number, gap: number): number {
return ((gap / 2) / r) * DEG;
}
/**
* Annular-sector ("donut slice") path between `rIn..rOut` over `a0..a1`, with a
* constant-pixel `gap` carved out of both angular sides. The per-radius padding
* makes the side edges slightly converge so the gap is the same width in px at
* the inner and outer arcs.
*/
export function sectorPath(
cx: number,
cy: number,
rIn: number,
rOut: number,
a0: number,
a1: number,
gap: number
): string {
const pIn = gapPadDeg(rIn, gap);
const pOut = gapPadDeg(rOut, gap);
let ai0 = a0 + pIn;
let ai1 = a1 - pIn;
const ao0 = a0 + pOut;
const ao1 = a1 - pOut;
if (ai1 <= ai0) {
const m = (a0 + a1) / 2;
ai0 = m - 0.05;
ai1 = m + 0.05;
}
const [xo0, yo0] = polar(cx, cy, rOut, ao0);
const [xo1, yo1] = polar(cx, cy, rOut, ao1);
const [xi1, yi1] = polar(cx, cy, rIn, ai1);
const [xi0, yi0] = polar(cx, cy, rIn, ai0);
const big = ao1 - ao0 > 180 ? 1 : 0;
return (
`M${round(xo0)} ${round(yo0)} A${rOut} ${rOut} 0 ${big} 1 ${round(xo1)} ${round(yo1)} ` +
`L${round(xi1)} ${round(yi1)} A${rIn} ${rIn} 0 ${big} 0 ${round(xi0)} ${round(yi0)} Z`
);
}
/** Baseline arc for an `angular` label. Reversed (flipped) on the lower half so text stays upright. */
export function labelArcPath(
cx: number,
cy: number,
rad: number,
a0: number,
a1: number,
flip: boolean
): string {
const A0 = flip ? a1 : a0;
const A1 = flip ? a0 : a1;
const sweep = flip ? 0 : 1;
const [x0, y0] = polar(cx, cy, rad, A0);
const [x1, y1] = polar(cx, cy, rad, A1);
const big = Math.abs(A1 - A0) > 180 ? 1 : 0;
return `M${round(x0)} ${round(y0)} A${rad} ${rad} 0 ${big} ${sweep} ${round(x1)} ${round(y1)}`;
}
export interface OnionOptionInput {
/** Whether this option has an icon to place (drives the label slot radius). */
readonly hasIcon: boolean;
/** What this option renders. Display is per-OPTION; disposition is per-ring. */
readonly display: OnionDisplay;
}
export interface OnionRingInput {
readonly options: readonly OnionOptionInput[];
readonly width: number;
readonly gap: number;
readonly disposition: OnionDisposition;
}
export interface OnionGeometryInput {
readonly placement: OnionPlacement;
readonly center: readonly [number, number];
/** Inner radius where the first ring begins (just outside the trigger). */
readonly innerRadius: number;
/** One entry per visible level: root first, then each drilled level outward. */
readonly rings: readonly OnionRingInput[];
}
/** Resolved label slot — exactly one disposition's fields are populated. */
export interface OnionLabel {
readonly disposition: OnionDisposition;
/** Length (px) available to the text; the renderer fits/truncates to it. */
readonly availablePx: number;
/** angular */
readonly arcPath?: string;
readonly flip?: boolean;
/** horizontal / vertical */
readonly x?: number;
readonly y?: number;
readonly rotate?: number;
}
export interface OnionSector {
readonly ring: number;
readonly index: number;
readonly a0: number;
readonly a1: number;
readonly mid: number;
readonly rIn: number;
readonly rOut: number;
readonly midR: number;
readonly path: string;
/** Icon box centre + size, when the option shows an icon for this display mode. */
readonly icon?: { readonly x: number; readonly y: number; readonly size: number };
/** Label slot, when the option shows text for this display mode. */
readonly label?: OnionLabel;
}
export interface OnionGeometry {
readonly start: number;
readonly span: number;
readonly outerRadius: number;
readonly sectors: readonly OnionSector[];
}
/**
* Compute the full onion layout. Pure: same input → same output, no DOM.
*/
export function computeOnionGeometry(input: OnionGeometryInput): OnionGeometry {
const [cx, cy] = input.center;
const { start, span } = onionArc(input.placement);
const sectors: OnionSector[] = [];
let ringStart = input.innerRadius;
for (let r = 0; r < input.rings.length; r++) {
const ring = input.rings[r];
const count = ring.options.length;
const rIn = ringStart + ring.gap / 2;
const rOut = ringStart + ring.width - ring.gap / 2;
const midR = (rIn + rOut) / 2;
ringStart += ring.width;
for (let i = 0; i < count; i++) {
const a0 = start + (span * i) / count;
const a1 = start + (span * (i + 1)) / count;
const mid = (a0 + a1) / 2;
const opt = ring.options[i];
const path = sectorPath(cx, cy, rIn, rOut, a0, a1, ring.gap);
const showIcon = opt.display !== 'text' && opt.hasIcon;
const showText =
opt.display === 'text' || opt.display === 'both' || (opt.display === 'icon' && !opt.hasIcon);
const paired = showIcon && showText;
// Text on the WIDER outer arc (more arc length to read), icon on the
// NARROWER inner arc (a compact glyph fits the shorter inner edge).
const textR = paired ? midR + ICON_OFFSET : midR;
const iconR = paired ? midR - ICON_OFFSET : midR;
let icon: OnionSector['icon'];
if (showIcon) {
const [ix, iy] = polar(cx, cy, iconR, mid);
icon = { x: round(ix), y: round(iy), size: ICON_SIZE };
}
let label: OnionLabel | undefined;
if (showText) {
label = computeLabel(cx, cy, ring.disposition, textR, rIn, rOut, a0, a1, mid, ring.gap);
}
sectors.push({ ring: r, index: i, a0, a1, mid, rIn, rOut, midR, path, icon, label });
}
}
return { start, span, outerRadius: ringStart, sectors };
}
function computeLabel(
cx: number,
cy: number,
disposition: OnionDisposition,
textR: number,
rIn: number,
rOut: number,
a0: number,
a1: number,
mid: number,
gap: number
): OnionLabel {
const padMid = gapPadDeg(textR, gap) + 1.6;
if (disposition === 'angular') {
const flip = mid > 90 && mid < 270;
const availablePx = (Math.max(0, a1 - a0 - 2 * padMid) * Math.PI) / 180 * textR;
return {
disposition,
availablePx,
flip,
arcPath: labelArcPath(cx, cy, textR, a0 + padMid, a1 - padMid, flip)
};
}
const [x, y] = polar(cx, cy, textR, mid);
if (disposition === 'horizontal') {
const chord = 2 * textR * Math.sin((Math.max(0, a1 - a0 - 2 * padMid) * Math.PI) / 360);
return { disposition, availablePx: chord, x: round(x), y: round(y) };
}
// vertical — text runs along the radius; rotate to stay upright on the lower half
let rotate = mid - 90;
if (rotate > 90 || rotate < -90) rotate += 180;
return { disposition, availablePx: rOut - rIn, x: round(x), y: round(y), rotate: round(rotate) };
}

@ -0,0 +1,38 @@
// OnionMenu — eidos radial ("onion") menu. Compositional, trigger-agnostic.
//
// import { OnionMenu, OnionLayer, OnionOption } from '$uix/eidos/components/onion-menu';
//
// <OnionMenu placement="bottom-end">
// {#snippet trigger()}<Button>Menu</Button>{/snippet}
// <OnionLayer textDisposition="angular" color="primary">
// <OnionOption label="Create" icon={createIcon}>
// <OnionLayer textDisposition="vertical" color="teal"> <!-- Create's children -->
// <OnionOption label="Doc" onSelect={…} />
// </OnionLayer>
// </OnionOption>
// <OnionOption label="Share" icon={shareIcon} onSelect={…} />
// </OnionLayer>
// </OnionMenu>
//
// The parts REGISTER into a reactive tree (via context); the root paints the SVG
// rings centrally from the pure geometry + colour engines. An option's children
// are a nested <OnionLayer> composed inside it (like DropdownMenu.Sub).
import OnionMenu from './onion-menu.svelte';
import OnionLayer from './onion-layer.svelte';
import OnionOption from './onion-option.svelte';
export { OnionMenu, OnionLayer, OnionOption };
export type {
OnionMenuProps,
OnionLayerProps,
OnionOptionProps,
OnionColor,
OnionPlacement,
OnionDisposition,
OnionDisplay
} from './types';
// Pure engines — exposed for advanced consumers + tests.
export { computeOnionGeometry, onionArc, ICON_SIZE } from './geometry';
export { onionBaseColor, deriveOnionShade, deriveOnionMuted, deriveOnionTrigger } from './color';

@ -0,0 +1,48 @@
<script lang="ts">
/**
* `<OnionLayer>` — one concentric ring / level. It does NOT paint; it registers
* an {@link OnionLayerNode} either as the menu's ROOT layer (when composed
* directly in `<OnionMenu>`) or as an option's CHILDREN (when composed inside an
* `<OnionOption>` — it reads the option registry). It exposes a layer registry
* so its `<OnionOption>`s register in order. The root `<OnionMenu>` paints.
*/
import { untrack } from 'svelte';
import {
OnionLayerNode,
getOnionOptionRegistry,
getOnionMenuRegistry,
setOnionLayerRegistry
} from './context.svelte';
import type { OnionLayerProps } from './types';
let { textDisposition, color, display, ringWidth, gap, children }: OnionLayerProps = $props();
const option = getOnionOptionRegistry();
const menu = getOnionMenuRegistry();
const node = new OnionLayerNode();
$effect(() => void (node.textDisposition = textDisposition));
$effect(() => void (node.color = color));
$effect(() => void (node.display = display));
$effect(() => void (node.ringWidth = ringWidth));
$effect(() => void (node.gap = gap));
// Register: composed inside an option → that option's children; else → root layer.
// `untrack` the writes (symmetry with OnionOption; keeps the effect single-run).
$effect(() => {
if (option) {
untrack(() => option.setChildLayer(node));
return () => untrack(() => option.setChildLayer(null));
}
untrack(() => menu?.setRootLayer(node));
return () => untrack(() => menu?.setRootLayer(null));
});
// Provide the layer registry for this level's options.
setOnionLayerRegistry({
registerOption: (n, seq) => node.insert(n, seq),
unregisterOption: (n) => node.remove(n)
});
</script>
{@render children?.()}

@ -0,0 +1,219 @@
/*
* OnionMenu recipe.
*
* The fills / text / stroke are computed per sector by the colour engine and
* applied inline (they depend on the live branch + drill state). This recipe
* owns only the structural chrome: the surface, the sector affordances + enter
* animation, the label / icon typography, and the trigger `+`→`×` glyph.
*
* [data-onion-menu] → provider container
* [data-onion-menu-surface] → the SVG menu (role="menu")
* [data-onion-menu-item] → an option sector (role="menuitem")
* [data-onion-menu-trigger] → the centre trigger
*/
[data-onion-menu-frame] {
display: inline-flex;
position: relative;
line-height: 0;
}
[data-onion-menu-surface] {
overflow: visible;
}
/* The surface is a container, never an outlined target — kill the default focus
ring on plain `:focus` too (click-focusing the SVG would otherwise draw a
black box). Item focus shows via the sector ring, not an outline. */
[data-onion-menu-surface]:focus,
[data-onion-menu-surface] :focus {
outline: none;
}
/* ── Sectors ─────────────────────────────────────────────────────────── */
.onion-menu-sector {
filter: drop-shadow(var(--onion-menu-sector-shadow));
transition:
fill var(--duration-fast) var(--ease-default),
opacity var(--duration-fast) var(--ease-default),
filter var(--duration-fast) var(--ease-default);
transform-box: fill-box;
transform-origin: center;
animation: onion-sector-in var(--duration-fast, 150ms) var(--ease-out, ease-out) both;
}
.onion-menu-sector[data-disabled] {
opacity: var(--opacity-muted, 0.55);
}
.onion-menu-sector:focus-visible {
outline: none;
stroke: var(--focus-ring-color, currentColor);
stroke-width: var(--onion-menu-sector-ring-width);
}
@keyframes onion-sector-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* ── Sector group — scopes the per-sector hover affordance ────────────── */
.onion-menu-sector-group {
cursor: pointer;
}
.onion-menu-sector-group.is-disabled {
cursor: default;
}
/* Passed-trail (muted) sectors dim their label + icon; the faded FILL is
already produced by the colour engine. */
.onion-menu-sector-group.is-muted .onion-menu-label,
.onion-menu-sector-group.is-muted .onion-menu-icon {
opacity: var(--onion-menu-muted-opacity);
}
/* Selection / active ring — the followed branch + the chosen leaf (the morfo's
active / selected state). Uses the on-solid token, not a raw colour. */
.onion-menu-sector-group.is-highlight .onion-menu-sector {
stroke: var(--color-content-on-solid);
stroke-width: var(--onion-menu-sector-ring-width);
}
/* Hover: lift the fill and bring the label / icon back to full strength so
the option reads as fully selectable (matters most on the muted trail). */
.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-sector {
filter: drop-shadow(var(--onion-menu-sector-shadow)) brightness(1.1) saturate(1.05);
}
/* A translucent ring is the primary "this one" signal — it reads on any fill
lightness, where brightness alone is too subtle. Skipped on highlighted
(active / selected) sectors: they already carry a solid ring we mustn't thin.
Uses the on-solid token at reduced opacity — the same colour as the selection
ring, kept lighter so hover reads as "candidate", not "chosen". */
.onion-menu-sector-group:not(.is-disabled):not(.is-highlight):hover .onion-menu-sector {
stroke: var(--color-content-on-solid);
stroke-width: var(--onion-menu-sector-hover-ring-width);
stroke-opacity: var(--onion-menu-hover-ring-opacity);
}
.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-label,
.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-icon {
opacity: 1;
}
/* ── Labels + icons ──────────────────────────────────────────────────── */
.onion-menu-label {
font-family: var(--style-label-font-family, var(--font-ui));
font-weight: var(--style-label-font-weight, 500);
pointer-events: none;
user-select: none;
transition: opacity var(--duration-fast) var(--ease-default);
}
.onion-menu-icon {
inline-size: 100%;
block-size: 100%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
/* clicks pass through the icon to the sector path beneath it */
pointer-events: none;
transition: opacity var(--duration-fast) var(--ease-default);
}
/* The glyph fills a proportion of its geometry box (ICON_SIZE) — a layout ratio,
not a fixed px, so it scales with the ring sizing. */
.onion-menu-icon :global(svg) {
inline-size: 70%;
block-size: 70%;
}
/* ── Trigger ─────────────────────────────────────────────────────────── */
/* A real HTML button overlaid at the SVG centre (positioned by inline left/top
%, centred by the transform). Its elevation is EXACTLY the FAB's — the recipe
wires `--onion-menu-trigger-shadow: var(--fab-shadow)` + `--onion-menu-trigger-lift:
var(--fab-lift)`, so the trigger shares the FAB's shadow + hover lift BY REFERENCE
(they can't drift). */
.onion-menu-trigger {
position: absolute;
transform: translate(-50%, -50%);
aspect-ratio: 1;
display: grid;
place-items: center;
border: 0;
padding: 0;
border-radius: var(--radius-full, 50%);
cursor: pointer;
color: var(--color-content-on-solid);
background: var(--_onion-trigger-fill);
box-shadow: var(--onion-menu-trigger-shadow);
/* FAB-style hover lift — `translate` composes with the centring `transform`. */
transition:
translate var(--duration-fast) var(--ease-out, ease-out),
box-shadow var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default);
}
.onion-menu-trigger:hover {
translate: 0 calc(-1 * var(--onion-menu-trigger-lift));
}
.onion-menu-trigger:focus-visible {
outline: 2px solid var(--focus-ring-color, currentColor);
outline-offset: 2px;
}
/* The `+`→`×` glyph — two bars built from pseudo-elements; the span rotates 45°
on open to cross them. */
.onion-menu-trigger-glyph {
position: relative;
inline-size: 42%;
block-size: 42%;
transition: transform var(--duration-normal, 300ms) var(--ease-default);
}
.onion-menu-trigger-glyph::before,
.onion-menu-trigger-glyph::after {
content: '';
position: absolute;
inset-inline: 0;
inset-block-start: 50%;
block-size: var(--onion-menu-glyph-bar-thickness);
margin-block-start: calc(var(--onion-menu-glyph-bar-thickness) / -2);
border-radius: var(--radius-full, 1px);
background: currentColor;
}
.onion-menu-trigger-glyph::after {
transform: rotate(90deg);
}
.onion-menu-trigger[data-state='open'] .onion-menu-trigger-glyph {
transform: rotate(45deg);
}
@media (prefers-reduced-motion: reduce) {
.onion-menu-sector {
animation: none;
transition: none;
}
.onion-menu-label,
.onion-menu-icon,
.onion-menu-trigger,
.onion-menu-trigger-glyph {
transition: none;
}
.onion-menu-trigger:hover {
translate: none;
}
}

@ -0,0 +1,515 @@
<script lang="ts">
import './onion-menu.css';
/**
* `<OnionMenu>` — the compositional radial ("onion") menu root. It provides the
* registration context that the composed `<OnionLayer>` / `<OnionOption>` parts
* build a reactive tree into, OWNS the open + drill state, and paints the SVG
* rings CENTRALLY from that tree via the pure geometry + colour engines. Parts
* declare structure; the root draws.
*
* Behaviour comes from the soma layer (`$soma/components/onion-menu`): the
* runtime emits the morfo's `open` / `close` / `commit-select` perceptual
* signals and dispatches the keyboard contract; the pure `onionNav*` helpers
* answer radial focus moves. This root drives focus, drill and dismiss against
* them — radial keyboard nav (Arrow / Enter / Backspace / Escape), focus into
* the ring on open + restore to the trigger on close, and outside-click / Tab
* dismissal (non-modal, per `onionMenuMorfo.focus`).
*/
import { ActiveEidos } from '$uix/eidos';
import {
createOnionMenuRuntime,
onionNavNext,
onionNavPrev,
onionNavFirst
} from '$soma/components/onion-menu';
import { computeOnionGeometry } from './geometry';
import { onionBaseColor, deriveOnionShade, deriveOnionMuted, deriveOnionTrigger } from './color';
import {
setOnionMenuRegistry,
type OnionLayerNode,
type OnionOptionNode
} from './context.svelte';
import type { OnionMenuProps } from './types';
let {
open = $bindable(false),
drillPath = $bindable([]),
selected = $bindable(null),
placement = 'bottom-end',
innerRadius = 82,
ringWidth = 66,
gap = 5,
fontSize = 13,
width = 680,
height = 620,
trigger,
onItemClick,
onTriggerClick,
children
}: OnionMenuProps = $props();
const M = 70;
const TRIGGER_R = 30;
// ── Registration: the composed parts build this reactive tree ──────────
// `$state.raw`: the layer node is a class instance carrying its own
// field-level reactivity — keep it un-proxied so the whole node tree
// (options, childLayer) keeps stable identity (no proxy-equality breakage).
let rootLayer = $state.raw<OnionLayerNode | null>(null);
let seq = 0;
setOnionMenuRegistry({
setRootLayer: (n) => (rootLayer = n),
nextSeq: () => ++seq
});
// ── Soma behaviour bridge: perceptual emission + keyboard dispatch ──────
const eidos = ActiveEidos.require();
const runtime = createOnionMenuRuntime({
dom: eidos.dom,
eventEngine: eidos.uix?.events,
announce: eidos.uix ? (m, p) => eidos.uix!.announce(m, p) : undefined,
actions: {
open: (e) => {
e.preventDefault();
openMenu();
},
close: (e) => {
e.preventDefault();
closeMenu();
},
'next-item': (e) => {
e.preventDefault();
moveFocus(1);
},
'prev-item': (e) => {
e.preventDefault();
moveFocus(-1);
},
activate: (e) => {
e.preventDefault();
activateFocused();
},
'drill-up': (e) => {
e.preventDefault();
drillUp();
}
}
});
// DOM refs (focus management + the perceptual signal target).
let frameEl = $state<HTMLDivElement | null>(null);
let surfaceEl = $state<SVGSVGElement | null>(null);
let triggerEl = $state<HTMLButtonElement | null>(null);
// Resolve a sector element by (level, index) by querying the live DOM — the
// sectors render in (ring, index) order, so the index-th item at `data-depth`
// is the one we want. Avoids `bind:this` into a computed key (Svelte 5 warns)
// and any ref-map timing race: the DOM is current when an effect reads it.
function sectorEl(level: number, index: number): SVGPathElement | null {
const ring = surfaceEl?.querySelectorAll<SVGPathElement>(
`[data-onion-menu-item][data-depth="${level + 1}"]`
);
return ring?.[index] ?? null;
}
// Roving focus within the deepest (current) ring.
let focusedIndex = $state(0);
let pendingFocus = $state<number | null>(null);
// The perceptual signal target is any Element (the engine just stamps attrs);
// our refs are SVG, so narrow to the runtime's HTMLElement slot at the boundary.
const asTarget = (el: Element | null | undefined): HTMLElement | undefined =>
(el ?? undefined) as HTMLElement | undefined;
function placeCenter(): [number, number] {
switch (placement) {
case 'center':
return [width / 2, height / 2];
case 'bottom-end':
return [width - M, height - M];
case 'bottom-start':
return [M, height - M];
case 'top-end':
return [width - M, M];
case 'top-start':
return [M, M];
case 'bottom-center':
return [width / 2, height - M];
case 'top-center':
return [width / 2, M];
case 'left-center':
return [M, height / 2];
case 'right-center':
return [width - M, height / 2];
}
}
const center = $derived(placeCenter());
// Visible levels = root, then one ring per drilled option (its children layer).
const visible = $derived.by(() => {
const levels: { layer: OnionLayerNode; options: OnionOptionNode[] }[] = [];
if (!rootLayer) return levels;
levels.push({ layer: rootLayer, options: rootLayer.options });
let cur = rootLayer;
for (let k = 0; k < drillPath.length; k++) {
const opt = cur.options[drillPath[k]];
const child = opt?.childLayer;
if (child && child.options.length) {
levels.push({ layer: child, options: child.options });
cur = child;
} else break;
}
return levels;
});
const branchBase = $derived(
drillPath.length && rootLayer
? onionBaseColor(drillPath[0], rootLayer.options[drillPath[0]]?.color)
: undefined
);
const geometry = $derived(
computeOnionGeometry({
placement,
center,
innerRadius,
rings: visible.map(({ layer, options }) => ({
options: options.map((o) => ({
hasIcon: !!o.icon,
display: o.display ?? layer.display ?? 'both'
})),
width: layer.ringWidth ?? ringWidth,
gap: layer.gap ?? gap,
disposition: layer.textDisposition ?? 'angular'
}))
})
);
type SectorView = {
path: string;
opt: OnionOptionNode;
ring: number;
index: number;
active: boolean;
isSelected: boolean;
muted: boolean;
highlight: boolean;
hasChildren: boolean;
fill: string;
text: string;
icon?: { x: number; y: number; size: number };
label?: { text: string; geom: NonNullable<(typeof geometry.sectors)[number]['label']> };
};
function fit(label: string | undefined, availablePx: number): string {
if (!label) return '';
const max = Math.floor(availablePx / (fontSize * 0.55));
if (label.length <= max) return label;
return max >= 2 ? label.slice(0, max - 1) + '…' : '';
}
const sectors = $derived.by<SectorView[]>(() =>
geometry.sectors.map((s) => {
const opt = visible[s.ring].options[s.index];
const depth = s.ring + 1;
const isCurrent = s.ring === visible.length - 1;
const active = drillPath[s.ring] === s.index;
const isSelected =
selected != null && selected.length === s.ring + 1 && selected[s.ring] === s.index;
const muted = !isCurrent && !active;
const base =
opt.color !== undefined
? onionBaseColor(s.index, opt.color)
: s.ring === 0
? onionBaseColor(s.index)
: branchBase!;
const shade = muted ? deriveOnionMuted(base) : deriveOnionShade(base, depth);
return {
path: s.path,
opt,
ring: s.ring,
index: s.index,
active,
isSelected,
muted,
highlight: active || isSelected,
hasChildren: opt.hasChildren,
fill: shade.fill,
text: shade.text,
icon: s.icon,
label: s.label ? { text: fit(opt.label, s.label.availablePx), geom: s.label } : undefined
};
})
);
const triggerFill = $derived(deriveOnionTrigger(branchBase));
const sid = (v: SectorView) => `om-${v.ring}-${v.index}`;
// ── Interaction (the menu owns its drill state) ────────────────────────
// The deepest visible ring is the one keyboard focus + activation act on.
const currentLevel = $derived(Math.max(0, visible.length - 1));
function currentRingNav() {
const opts = visible[currentLevel]?.options ?? [];
const disabled: number[] = [];
opts.forEach((o, i) => o.disabled && disabled.push(i));
return { count: opts.length, disabled };
}
function focusAt(i: number) {
focusedIndex = i;
sectorEl(currentLevel, i)?.focus();
}
function openMenu() {
if (open) return;
open = true;
runtime.trigger('open', { fallbackTarget: asTarget(surfaceEl) });
pendingFocus = onionNavFirst(currentRingNav());
onTriggerClick?.();
}
function closeMenu(restoreFocus = true) {
if (!open) return;
open = false;
drillPath = [];
selected = null;
focusedIndex = 0;
pendingFocus = null;
runtime.trigger('close', { fallbackTarget: asTarget(surfaceEl) });
if (restoreFocus) triggerEl?.focus();
}
function clickItem(ring: number, index: number) {
const opt = visible[ring]?.options[index];
if (!opt || opt.disabled) return;
if (opt.hasChildren) {
drillPath = [...drillPath.slice(0, ring), index];
selected = null;
pendingFocus = onionNavFirst(currentRingNav());
} else {
const dp = drillPath.slice(0, ring);
drillPath = dp;
selected = [...dp, index];
opt.onSelect?.();
runtime.trigger('commit-select', {
fallbackTarget: asTarget(sectorEl(ring, index) ?? surfaceEl)
});
}
onItemClick?.(ring, index);
}
function clickTrigger() {
if (open) closeMenu();
else openMenu();
}
// Keyboard actions (dispatched by the soma runtime against the morfo contract).
function moveFocus(dir: 1 | -1) {
const nav = currentRingNav();
const next = dir > 0 ? onionNavNext(focusedIndex, nav) : onionNavPrev(focusedIndex, nav);
if (next >= 0) focusAt(next);
}
function activateFocused() {
clickItem(currentLevel, focusedIndex);
}
function drillUp() {
if (drillPath.length === 0) {
closeMenu();
return;
}
const parentIdx = drillPath[drillPath.length - 1];
drillPath = drillPath.slice(0, -1);
selected = null;
pendingFocus = parentIdx;
}
// The trigger is an HTML button (its own keydown → trigger contract); the SVG
// surface routes sector keys. Enter/Space on the button: the `open` action
// preventDefaults, so the native click doesn't also fire (no double-toggle).
function onSurfaceKeydown(e: KeyboardEvent) {
runtime.keydown('surface', e);
}
function onTriggerKeydown(e: KeyboardEvent) {
runtime.keydown('trigger', e);
}
function onFocusout(e: FocusEvent) {
// Non-modal (morfo `focus.trap: false`): focus leaving the frame (Tab out)
// dismisses. Ignore null relatedTarget (transient blur) and intra-frame moves.
const next = e.relatedTarget as Node | null;
if (open && next && frameEl && !frameEl.contains(next)) closeMenu(false);
}
// Move focus into the ring on a controlled open TRANSITION too (not initial
// mount — that would steal focus from the page when `open` defaults true).
// `openMenu` already queues focus for trigger-driven opens; this covers a
// consumer that flips `open` programmatically.
let hasMounted = false;
let prevOpen = false;
$effect(() => {
const isOpen = open;
if (hasMounted && isOpen && !prevOpen && pendingFocus == null) {
pendingFocus = onionNavFirst(currentRingNav());
}
prevOpen = isOpen;
});
$effect(() => {
hasMounted = true;
});
// Focus into the current ring after it (re)renders — open, drill, drill-up.
$effect(() => {
void sectors;
if (pendingFocus == null) return;
const el = sectorEl(currentLevel, pendingFocus);
if (el) {
el.focus();
focusedIndex = pendingFocus;
pendingFocus = null;
}
});
// Outside-click dismissal while open (non-modal).
$effect(() => {
if (!open || !frameEl) return;
const doc = eidos.dom.getDocument(frameEl);
const onDown = (e: Event) => {
const t = e.target as Node | null;
if (frameEl && t && !frameEl.contains(t)) closeMenu(false);
};
doc.addEventListener('pointerdown', onDown, true);
return () => doc.removeEventListener('pointerdown', onDown, true);
});
</script>
<!-- Registration mount: the composed parts run here (no paint). The SVG below
reads the registered tree and draws. -->
<div data-onion-menu data-placement={placement} style="display:contents">
<div style="display:none" aria-hidden="true">{@render children?.()}</div>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div data-onion-menu-frame bind:this={frameEl} onfocusout={onFocusout}>
<!-- The SVG paints only the rings; the trigger is an HTML button overlaid at
the centre (so it uses the canonical depth/halo elevation + a reliable
hover lift). Sector keydown bubbles here → the surface contract. -->
<svg
bind:this={surfaceEl}
{width}
viewBox="0 0 {width} {height}"
role="menu"
tabindex="-1"
data-onion-menu-surface
data-state={open ? 'open' : 'closed'}
aria-label="Onion menu"
onkeydown={onSurfaceKeydown}
>
<defs>
{#each sectors as v (sid(v))}
{#if v.label && v.label.text && v.label.geom.disposition === 'angular'}
<path id="{sid(v)}-lp" d={v.label.geom.arcPath} fill="none" />
{/if}
{/each}
</defs>
{#if open}
{#each sectors as v (sid(v))}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- Keyboard activation is owned by the soma layer; the sector is a
passive click target with role/aria for AT here. The <g> wrapper
scopes the hover affordance (brighten the fill + un-dim the
label / icon) to this one sector. -->
<g
class="onion-menu-sector-group"
class:is-muted={v.muted}
class:is-highlight={v.highlight}
class:is-disabled={v.opt.disabled}
>
<!-- Fill is a theme-token CSS colour (color-mix of `--scale-*`), so it
goes via `style` — SVG presentation attrs reject CSS colour funcs.
The highlight / hover rings live in the recipe, keyed on the group
class, and use `--color-content-on-solid` (no raw colours here). -->
<path
d={v.path}
style="fill: {v.fill};"
data-onion-menu-item
data-depth={v.ring + 1}
data-active={v.active ? '' : undefined}
data-muted={v.muted ? '' : undefined}
data-disabled={v.opt.disabled ? '' : undefined}
role="menuitem"
aria-haspopup={v.hasChildren ? 'menu' : undefined}
aria-expanded={v.hasChildren ? (v.active ? 'true' : 'false') : undefined}
aria-disabled={v.opt.disabled ? 'true' : undefined}
aria-label={v.opt.label}
tabindex="-1"
class="onion-menu-sector"
onclick={() => clickItem(v.ring, v.index)}
/>
{#if v.label && v.label.text}
{@const g = v.label.geom}
{#if g.disposition === 'angular'}
<text
class="onion-menu-label"
dy={fontSize * 0.34}
style="font-size:{fontSize}px; fill:{v.text};"
>
<textPath href="#{sid(v)}-lp" startOffset="50%" style="text-anchor:middle"
>{v.label.text}</textPath
>
</text>
{:else}
<text
class="onion-menu-label"
x={g.x}
y={g.y}
text-anchor="middle"
dominant-baseline="central"
transform={g.rotate ? `rotate(${g.rotate} ${g.x} ${g.y})` : undefined}
style="font-size:{fontSize}px; fill:{v.text};"
>{v.label.text}</text
>
{/if}
{/if}
{#if v.icon}
<!-- pointer-events none so a click on the icon passes through to the
sector path beneath it (otherwise the icon swallows the click). -->
<foreignObject
x={v.icon.x - v.icon.size / 2}
y={v.icon.y - v.icon.size / 2}
width={v.icon.size}
height={v.icon.size}
pointer-events="none"
>
<div class="onion-menu-icon" style="color:{v.text};" xmlns="http://www.w3.org/1999/xhtml">
{@render v.opt.icon?.()}
</div>
</foreignObject>
{/if}
</g>
{/each}
{/if}
</svg>
<!-- Center trigger — a real HTML button overlaid at the SVG centre. It uses
the canonical depth/halo elevation (reads on light AND dark) + a reliable
hover lift, which an SVG element can't do (no box-shadow / inset). The
custom `trigger` snippet renders as its content; else the +→× glyph. -->
<button
bind:this={triggerEl}
type="button"
class="onion-menu-trigger"
data-onion-menu-trigger
data-state={open ? 'open' : 'closed'}
style="left:{(center[0] / width) * 100}%; top:{(center[1] / height) * 100}%; inline-size:{((TRIGGER_R * 2) / width) * 100}%; --_onion-trigger-fill:{triggerFill};"
aria-haspopup="menu"
aria-expanded={open}
onclick={clickTrigger}
onkeydown={onTriggerKeydown}
>
{#if trigger}
{@render trigger()}
{:else}
<span class="onion-menu-trigger-glyph" aria-hidden="true"></span>
{/if}
</button>
</div>
</div>

@ -0,0 +1,56 @@
<script lang="ts">
/**
* `<OnionOption>` — one option (sector). It does NOT paint; it registers an
* {@link OnionOptionNode} into the enclosing `<OnionLayer>` (in declaration
* order via a monotonic seq), and exposes an option registry so a nested
* `<OnionLayer>` composed inside it becomes this option's children. The root
* `<OnionMenu>` paints the sector from the registered node.
*/
import { untrack } from 'svelte';
import {
OnionOptionNode,
getOnionLayerRegistry,
getOnionMenuRegistry,
setOnionOptionRegistry
} from './context.svelte';
import type { OnionOptionProps } from './types';
let {
label,
icon,
color,
display,
disabled = false,
onSelect,
children
}: OnionOptionProps = $props();
const layer = getOnionLayerRegistry();
const menu = getOnionMenuRegistry();
const node = new OnionOptionNode();
// seq captured at init = document order (the layer sorts by it).
const seq = menu ? menu.nextSeq() : 0;
// Keep the node reactive to live props.
$effect(() => void (node.label = label));
$effect(() => void (node.icon = icon));
$effect(() => void (node.color = color));
$effect(() => void (node.display = display));
$effect(() => void (node.disabled = disabled));
$effect(() => void (node.onSelect = onSelect));
// Register into the layer (insert-by-seq keeps markup order); clean up on unmount.
// `untrack`: insert/remove READ the layer's `options` (slice / indexOf) to build
// the next array — without untrack this effect would subscribe to `options` and
// re-run on its own write (register → write → invalidate → unregister → register…),
// an infinite loop. Deps (layer / node / seq) are const, so it runs exactly once.
$effect(() => {
untrack(() => layer?.registerOption(node, seq));
return () => untrack(() => layer?.unregisterOption(node));
});
// A nested <OnionLayer> registers as this option's children.
setOnionOptionRegistry({ setChildLayer: (l) => (node.childLayer = l) });
</script>
{@render children?.()}

@ -0,0 +1,91 @@
import type { Snippet } from 'svelte';
import type { OnionPlacement, OnionDisposition, OnionDisplay } from './geometry';
export type { OnionPlacement, OnionDisposition, OnionDisplay };
/**
* A branch / option colour — a canonical THEME colour name: a Radix-style scale
* (`'teal'`, `'indigo'`, `'orange'`, …) or a role (`'primary'`, `'affirm'`, …).
* The render resolves it to the theme's `--scale-*` / `--primitive-*` tokens, so
* the menu always tracks the active theme. Omit it to auto-assign a distinct
* default per top-level branch.
*/
export type OnionColor = string;
/**
* Props for the root `<OnionMenu>`. The menu is COMPOSITIONAL: it holds the
* trigger + one root `<OnionLayer>`; options nest their children by composing a
* `<OnionLayer>` inside an `<OnionOption>` (like `DropdownMenu.Sub`). The root
* owns the open + drill state, collects the composed tree via context, and
* paints the SVG rings centrally from the geometry + colour engines.
*/
export interface OnionMenuProps {
/** Anchor → arc span (90 / 180 / 360) + orientation. @default 'bottom-end' */
placement?: OnionPlacement;
/** Whether the rings show. Bindable. @default false */
open?: boolean;
/** Child indices of the active drill path. Bindable. @default [] */
drillPath?: number[];
/** Index path to the currently-selected leaf, or null. Bindable. */
selected?: number[] | null;
/** Inner radius where ring 1 begins (just outside the trigger). @default 82 */
innerRadius?: number;
/** Default radial thickness per ring (a layer can override). @default 66 */
ringWidth?: number;
/** Default fixed pixel gap between segments (a layer can override). @default 5 */
gap?: number;
/** Label font size in SVG px (≥ sm). @default 13 */
fontSize?: number;
/** SVG viewBox width. @default 680 */
width?: number;
/** SVG viewBox height. @default 620 */
height?: number;
/** Center trigger content (any element). Defaults to a + that rotates to ×. */
trigger?: Snippet;
/** Clicked the option at ring `ring` (0-based) / sector `index`. */
onItemClick?: (ring: number, index: number) => void;
/** Clicked the center trigger. */
onTriggerClick?: () => void;
/** The root `<OnionLayer>` (+ any registration-only descendants). */
children?: Snippet;
}
/**
* Props for `<OnionLayer>` — one concentric ring / level. The root layer is the
* first ring; a layer composed inside an `<OnionOption>` is that option's
* children. Its config applies to all the options it holds.
*/
export interface OnionLayerProps {
/** How this level's labels are laid out. @default 'angular' */
textDisposition?: OnionDisposition;
/** Base colour when this level is a drilled branch (else inherited/derived). */
color?: OnionColor;
/** Default display for the level's options. @default 'both' */
display?: OnionDisplay;
/** Radial thickness of this ring. */
ringWidth?: number;
/** Fixed pixel gap for this ring. */
gap?: number;
/** The `<OnionOption>`s of this level. */
children?: Snippet;
}
/**
* Props for `<OnionOption>` — one option (sector). If it has children, compose a
* `<OnionLayer>` inside it (the children pass through `children`).
*/
export interface OnionOptionProps {
/** Visible label (and accessible name). */
label?: string;
/** Glyph snippet (rendered via a framework `<Icon>` / `<Svg*>`). */
icon?: Snippet;
/** Branch base (top option) or per-option override. Auto-distinct if omitted. */
color?: OnionColor;
/** Per-option display override. */
display?: OnionDisplay;
disabled?: boolean;
/** Fired when this LEAF option is chosen. */
onSelect?: () => void;
/** A nested `<OnionLayer>` = this option's children (makes it a branch). */
children?: Snippet;
}

@ -0,0 +1,150 @@
# SplitButton
A primary action joined to a ▾ menu trigger that opens secondary actions —
`Save | ▾` → Save as…, Save a copy…. The primary runs its own `onclick`; **only
the ▾ opens the menu**.
```svelte
<script lang="ts">
import { SplitButton } from '$uix/eidos/components/split-button';
let open = $state(false);
</script>
<SplitButton bind:open variant="solid" aria-label="Save options">
<SplitButton.Primary onclick={save}>Save</SplitButton.Primary>
<SplitButton.Menu>
<SplitButton.Item onSelect={saveAs}>Save as…</SplitButton.Item>
<SplitButton.Item onSelect={saveCopy}>Save a copy</SplitButton.Item>
<SplitButton.Separator />
<SplitButton.Item data-color="threat" onSelect={discard}>Discard</SplitButton.Item>
</SplitButton.Menu>
</SplitButton>
```
## Baseline
No Air baseline. SplitButton is **pure composition** of four existing
components — nothing reinvented:
- **`<ButtonGroup attached>`** — the segmented group (role=group + label, the
one-border-per-seam segmenting, and the variant/size/color propagation
context we built for it).
- **`<Button>`** — the primary action (`<SplitButton.Primary>`).
- **`<DropdownMenu>`** — the ▾ trigger (its `.Trigger`, auto-rendered as an
icon-only Button) + the menu (`.Content` → `<SplitButton.Menu>`, `.Item`, …).
- **`<SvgChevron>`** — the default ▾ glyph (rotates while open).
## Comparativa
| Capability | Fluent UI | Ant `Dropdown.Button` | PrimeReact | Bootstrap | MUI (recipe) | **UIX** |
| --- | --- | --- | --- | --- | --- | --- |
| Dedicated component | ✓ | ✓ | ✓ | pattern | recipe | ✓ |
| Primary action + separate ▾ menu trigger | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Menu = framework menu engine | ✓ | Menu | `model` | dropdown | Menu | **DropdownMenu** (full: groups/checkbox/radio/sub) |
| variant/size/color shared to both buttons | ✓ | ✓ | severity | classes | manual | **via ButtonGroup context** |
| Segmented (attached) seam | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| ▾ glyph default + custom | ✓ | ✓ | ✓ | — | — | ✓ (`icon` prop) |
| Chevron rotates while open | some | — | — | — | — | ✓ |
WAI-ARIA: there is no dedicated APG "split button" pattern — it's a button + a
[Menu Button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) (which
DropdownMenu implements: `aria-haspopup="menu"` + `aria-expanded`, arrow/typeahead
keyboard, focus return). The split adds the `role="group"` container.
## Decisiones
- **Has its own morfo (`scope: ['eidos']`).** Even though it composes, the morfo
is where the split declares its own contract: the `role="group"` provider, the
`data-state` (open/closed) that **drives the chevron rotation** (morfo declares
the hook, eidos animates), and the Primary / Trigger parts as structural
identities. The composed components keep their own ARIA — this morfo references
(`aria-haspopup`, the menu role), never re-stamps, them.
- **Composes `<ButtonGroup attached>`** rather than re-segmenting. Both buttons
inherit variant/size/color from the ButtonGroup context — including the
auto-rendered ▾ trigger, which is rendered via the Trigger's `child` snippet as
a plain `<Button iconOnly>` **without** an explicit variant so it picks up the
group's. This is why the two halves always match.
- **▾ trigger auto-rendered, glyph overridable (`icon`).** The common case needs
only Primary + Menu items; the chevron appears between them automatically
(Fluent/Ant pattern). A fully custom trigger is an escape hatch: compose
`<DropdownMenu>` + `<ButtonGroup>` directly.
- **▾ trigger is a slim tail, not a second square.** An `iconOnly` Button is a
square (`inline-size` = its height); for a split button that reads unbalanced.
The recipe sizes it to the glyph plus a snug inline padding (`--split-button-trigger-px`)
so the ▾ stays clearly subordinate to the primary while sharing its height.
- **The menu anchors to the whole group, not the ▾.** `<SplitButton.Menu>` reads
the group element from a SplitButton context and passes it as the
`DropdownMenu.Content` `customAnchor`, so the popup aligns to the split
surface's leading edge. Threading `customAnchor` through the soma `DropdownMenu`
was a parity fix — `Combobox.Content` already had it; the menu needs it because
the ▾ trigger is narrower than the surface it belongs to.
- **The float's WIDTH tracks the parent.** `matchAnchorWidth` (default on) makes
the panel's `min-inline-size` the anchor (group) width via floating-ui's
`--floating-anchor-width` — so the float is *at least* the split surface and its
content can grow it. There is no arbitrary `12rem` floor competing with the
parent; "how wide is the float" is answered by "how wide is the button".
- **The float SCALES with the split `size`.** The size rides the SplitButton
context to `<SplitButton.Menu>`, which forwards it to `<DropdownMenu.Content
size>`. The menu's typographic + density scale (1:1 with the controls, flowing
to submenus) lives on **DropdownMenu's own `size` axis** — SplitButton does not
reimplement it. SplitButton owns only its gap step per size (below).
- **The separation is canonical.** With no explicit `sideOffset`, the gap is a
`--space-*` step keyed by size (4 / 6 / 8 / 10 / 12 for xs…xl), applied as a CSS
margin on the placed side — a real token length, so it follows density × scaling.
floating-ui's `sideOffset` is a JS number and can't take a token; the margin
keeps it canonical (the wrapper is `position: fixed`, a BFC, so it doesn't
collapse). Passing a numeric `sideOffset` opts back into floating-ui's px offset.
- **Compositional shell, menu re-exported.** `<SplitButton.Item>` / `.Group` /
`.CheckboxItem` / `.Sub`… are the DropdownMenu parts under one namespace — the
full menu surface for free, no second implementation.
- **Only the ▾ opens the menu.** A single button whose whole face opens a menu is
a *menu button* (`<DropdownMenu.Trigger endIcon={chevron}>`), not a split
button — out of scope here by definition.
## Subset
SplitButton forwards visual knobs to both buttons; it owns no `data-color` recipe.
- **variant** (propagated): full `ButtonVariant` (`solid`/`soft`/`surface`/`outline`/`ghost`/`plain`).
- **size** (propagated): `ButtonSize` (`xs..xl`).
- **color** (propagated): `primary` / `secondary` / `neutral`. Evaluative tone is
per-element (per-event intent doctrine): `<SplitButton.Primary intent="…">` for
the action; `<SplitButton.Item data-color="threat">` for a destructive menu
item (DropdownMenu items take `data-color`, not `intent`).
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `<SplitButton.Trigger>` explicit part | **diferir** | Auto-render + `icon` covers the common case; a fully custom trigger drops to composing DropdownMenu + ButtonGroup directly. |
| Loading state on primary | **descartar** | Already inherited — `<SplitButton.Primary loading>` works (Button prop). |
| `orientation` / `block` | **diferir** | A split button is horizontal by definition; expose if a real consumer needs a vertical/stretched split. |
## Audit exceptions
Like `icon` / `button-group` (the eidos-only precedent), `component:audit` reports
two `error` rows that are **audit limitations for `scope:['eidos']`
components**, not defects:
- **`E-2.2`** — the recipe is code-split (`split-button.svelte` self-imports
`./split-button.css`), so it is intentionally NOT `@import`ed in `index.css`
(which would double-load it).
- **`D-3.1`** — eidos-only, so the demo ships only the `eidosSnippet` (there is no
soma layer for a `somaSnippet`).
## Sema events
0 events on SplitButton's own morfo. It is interactive **by composition**: the
primary fires `contact-activate` (Button), the ▾ fires `emerge.open`/`close`
(DropdownMenu), and each item fires `commit-select` (DropdownMenu) — visible in
the demo trace strip.
## Passive justification
SplitButton's morfo declares 0 sema events because the component is a
*structural frame* — it owns no perceptual moment of its own. Per CANON §5 the
signal belongs to the act inside the frame, not the frame itself. Every
perceptual event the user experiences (the primary press, the ▾ open/close, an
item select) is owned by the composed Button / DropdownMenu morfos; SplitButton
only contributes the `data-state` open/closed that lets eidos rotate the
chevron.

@ -0,0 +1,32 @@
import { getContext, setContext } from 'svelte';
import type { ButtonSize } from '$uix/eidos/components/button';
const SPLIT_BUTTON_EIDOS_CONTEXT = Symbol('uix.eidos.split-button');
export interface SplitButtonEidosContext {
/**
* The segmented group element (`[data-split-button]`). `<SplitButton.Menu>`
* reads it to use as the floating anchor, so the popup aligns to the WHOLE
* split surface — same leading edge, and `--floating-anchor-width` reports
* the group's width so the menu can match it — instead of anchoring to the
* bare ▾ trigger. Null until the group mounts.
*/
getAnchorEl(): HTMLElement | null;
/**
* The resolved split `size`. The menu reads it to scale its own typography /
* density and to pick the canonical `--space-*` gap step, so the float tracks
* the parent instead of staying frozen at the DropdownMenu default.
*/
getSize(): ButtonSize;
}
export function setSplitButtonEidosContext(
ctx: SplitButtonEidosContext
): SplitButtonEidosContext {
setContext(SPLIT_BUTTON_EIDOS_CONTEXT, ctx);
return ctx;
}
export function getSplitButtonEidosContext(): SplitButtonEidosContext | undefined {
return getContext<SplitButtonEidosContext | undefined>(SPLIT_BUTTON_EIDOS_CONTEXT);
}

@ -0,0 +1,68 @@
// SplitButton — eidos compound API.
//
// import { SplitButton } from '$uix/eidos/components/split-button';
//
// <SplitButton bind:open variant="solid" aria-label="Save options">
// <SplitButton.Primary onclick={save}>Save</SplitButton.Primary>
// <SplitButton.Menu>
// <SplitButton.Item onSelect={saveAs}>Save as…</SplitButton.Item>
// <SplitButton.Item onSelect={saveCopy}>Save a copy</SplitButton.Item>
// <SplitButton.Separator />
// <SplitButton.Item intent="threat" onSelect={discard}>Discard</SplitButton.Item>
// </SplitButton.Menu>
// </SplitButton>
//
// The ▾ trigger is auto-rendered (a chevron that rotates while the menu is
// open); override its glyph with the `icon` prop. The menu surface
// (`Menu`/`Item`/`Separator`/`Group`/checkbox/radio/sub…) is re-exported
// from `<DropdownMenu>` — the menu IS a DropdownMenu.
import SplitButtonComponent from './split-button.svelte';
import Primary from './split-button-primary.svelte';
import Menu from './split-button-menu.svelte';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
type SplitButtonNamespace = typeof SplitButtonComponent & {
Primary: typeof Primary;
Menu: typeof Menu;
Item: typeof DropdownMenu.Item;
Separator: typeof DropdownMenu.Separator;
Group: typeof DropdownMenu.Group;
GroupHeading: typeof DropdownMenu.GroupHeading;
CheckboxItem: typeof DropdownMenu.CheckboxItem;
CheckboxGroup: typeof DropdownMenu.CheckboxGroup;
RadioGroup: typeof DropdownMenu.RadioGroup;
RadioItem: typeof DropdownMenu.RadioItem;
Sub: typeof DropdownMenu.Sub;
SubTrigger: typeof DropdownMenu.SubTrigger;
SubContent: typeof DropdownMenu.SubContent;
Arrow: typeof DropdownMenu.Arrow;
};
const SplitButton = SplitButtonComponent as SplitButtonNamespace;
SplitButton.Primary = Primary;
SplitButton.Menu = Menu;
SplitButton.Item = DropdownMenu.Item;
SplitButton.Separator = DropdownMenu.Separator;
SplitButton.Group = DropdownMenu.Group;
SplitButton.GroupHeading = DropdownMenu.GroupHeading;
SplitButton.CheckboxItem = DropdownMenu.CheckboxItem;
SplitButton.CheckboxGroup = DropdownMenu.CheckboxGroup;
SplitButton.RadioGroup = DropdownMenu.RadioGroup;
SplitButton.RadioItem = DropdownMenu.RadioItem;
SplitButton.Sub = DropdownMenu.Sub;
SplitButton.SubTrigger = DropdownMenu.SubTrigger;
SplitButton.SubContent = DropdownMenu.SubContent;
SplitButton.Arrow = DropdownMenu.Arrow;
export { SplitButton };
export default SplitButton;
export type {
SplitButtonProps,
SplitButtonVariant,
SplitButtonSize,
SplitButtonColor,
SplitButtonPrimaryProps,
SplitButtonMenuProps,
SplitButtonItemProps
} from './types';

@ -0,0 +1,77 @@
<script lang="ts">
/**
* Eidos `<SplitButton.Menu>` — the floating menu panel.
*
* It IS a `<DropdownMenu.Content>`, wrapped only to give it split-button
* defaults the bare re-export couldn't:
*
* - **Anchored to the whole group, not the ▾.** Reads the group element
* from the SplitButton context and passes it as `customAnchor`, so the
* popup aligns to the split surface's leading edge and floating-ui's
* `--floating-anchor-width` reports the GROUP's width (not the narrow
* trigger's).
* - **Width tracks the parent.** `matchAnchorWidth` (default on) exposes
* `--_split-button-menu-anchor-width: var(--floating-anchor-width)`, which
* the recipe uses as the panel's `min-inline-size` — so the float is at
* least the surface width and grows with its content. No arbitrary floor.
* - **Scales with the split `size`.** Forwards the size to
* `<DropdownMenu.Content size>`, which owns the menu's typographic /
* density scale (1:1 with the controls) and stamps `data-size`. SplitButton
* only keys its own gap step off that `data-size`.
* - **A canonical gap below the button.** When `sideOffset` isn't set, the
* separation is a `--space-*` step keyed by size, applied as a CSS margin by
* the recipe (`data-canonical-gap`) — a real custom-property length, so it
* follows density × scaling. (We can't feed floating-ui's numeric
* `sideOffset` a token; the margin keeps it canonical. The floating wrapper
* is a BFC, so the margin doesn't collapse.) Passing `sideOffset` opts back
* into floating-ui's px offset and drops the canonical margin.
*
* Every Content prop still passes through and overrides these defaults.
*/
import { ActiveEidos } from '$uix/eidos';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { getSplitButtonEidosContext } from './context';
import type { SplitButtonMenuProps } from './types';
let {
size,
sideOffset,
matchAnchorWidth = true,
style,
children: bodyContent,
...rest
}: SplitButtonMenuProps = $props();
const eidos = ActiveEidos.require();
const split = getSplitButtonEidosContext();
const anchorEl = $derived(split?.getAnchorEl() ?? null);
// Default to the split's size; an explicit `size` on the Menu overrides it.
const resolvedSize = $derived(eidos.resolve(size ?? split?.getSize(), 'md'));
const resolvedMatchAnchorWidth = $derived(eidos.resolve(matchAnchorWidth, true));
// No explicit sideOffset → use the canonical CSS-margin gap (token-driven).
const canonicalGap = $derived(sideOffset === undefined);
const composedStyle = $derived(
composeInlineStyle(
resolvedMatchAnchorWidth
? '--_split-button-menu-anchor-width: var(--floating-anchor-width);'
: undefined,
style
)
);
</script>
<DropdownMenu.Content
{...rest}
size={resolvedSize}
sideOffset={sideOffset ?? 0}
customAnchor={anchorEl}
style={composedStyle}
data-split-button-menu=""
data-canonical-gap={canonicalGap ? '' : undefined}
>
{#snippet children(snippetProps: { open: boolean })}
{@render bodyContent?.(snippetProps)}
{/snippet}
</DropdownMenu.Content>

@ -0,0 +1,16 @@
<script lang="ts">
/**
* Eidos <SplitButton.Primary> — the primary action. It IS a `<Button>`;
* it inherits variant / size / color / disabled from the enclosing
* ButtonGroup context, so the consumer passes only behaviour
* (`onclick`, `type`, `loading`, `intent`, …) and the label as children.
* The `data-split-button-primary` marker lands on the same `<button>`
* Button renders.
*/
import { Button } from '$uix/eidos/components/button';
import type { SplitButtonPrimaryProps } from './types';
let props: SplitButtonPrimaryProps = $props();
</script>
<Button {...props} data-split-button-primary="" />

@ -0,0 +1,115 @@
/*
* SplitButton recipe.
*
* Layout + the segmented seam come from the composed <ButtonGroup attached>;
* <Button> and <DropdownMenu> paint the two buttons. This recipe owns the
* split-specific affordances:
* 1. the ▾ chevron rotates 180° while the menu is open;
* 2. the ▾ trigger is a slim tail, not a second square;
* 3. the floating menu is at least as wide as the split surface.
* The open/closed state lives on the group element
* (`[data-split-button][data-state]`, stamped by the wrapper from the bound
* `open`).
*
* We rotate the Button's icon SLOT (`[data-button-icon]`, a plain HTML span)
* rather than the inner `<svg>` — CSS `transform` on the span is rock-solid,
* whereas an `<svg>`'s transform-box/origin makes a rule-driven rotation
* unreliable. `:has([data-svg='chevron'])` scopes it to the default chevron,
* so a custom `icon` override stays still — intentional.
*/
[data-split-button] [data-split-button-trigger] [data-button-icon]:has([data-svg='chevron']) {
transition: transform var(--duration-fast) var(--ease-default);
}
[data-split-button][data-state='open']
[data-split-button-trigger]
[data-button-icon]:has([data-svg='chevron']) {
transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
[data-split-button] [data-split-button-trigger] [data-button-icon]:has([data-svg='chevron']) {
transition: none;
}
}
/*
* ▾ trigger is NARROWER than the primary. An icon-only <Button> is a square
* (`inline-size: var(--_button-height)`); for a split button that reads
* unbalanced — the ▾ should be a slim tail, not a second square. We size it to
* the glyph plus a snug inline padding so it stays clearly subordinate to the
* primary while keeping the shared height. Higher specificity than button.css's
* `[data-button][data-icon-only]` so it wins regardless of load order.
*/
[data-split-button] [data-split-button-trigger][data-icon-only] {
inline-size: auto;
padding-inline: var(--split-button-trigger-px);
}
/*
* The float's WIDTH tracks the parent. SplitButton.Menu anchors the panel to the
* whole group, so floating-ui's `--floating-anchor-width` (mirrored here as
* `--_split-button-menu-anchor-width` when matchAnchorWidth is on) IS the split
* surface width. That becomes the panel's `min-inline-size` — the float is at
* least the surface and its content can grow it; there is no arbitrary 12rem
* floor competing with the parent. When matchAnchorWidth is off the var is unset
* and we inherit the DropdownMenu's own default.
*
* Both `[data-dropdown-menu-content]` markers so this OUT-specifies (0,2,0) the
* base panel's `[data-dropdown-menu-content]` min-inline-size (0,1,0) — equal
* specificity would lose on load order since dropdown-menu.css is global and
* this recipe is code-split.
*/
[data-dropdown-menu-content][data-split-button-menu] {
min-inline-size: var(
--_split-button-menu-anchor-width,
var(--dropdown-menu-content-min-width, 12rem)
);
}
/*
* The float SCALES with the split `size` — but the menu's typography / density
* scale is the DropdownMenu's OWN `size` axis now (SplitButton.Menu forwards the
* size to `<DropdownMenu.Content size>`; see dropdown-menu.css). SplitButton owns
* only the SEPARATION step per size: the canonical `--space-*` gap below the
* button, keyed off the `data-size` the DropdownMenu stamps.
*/
[data-split-button-menu][data-size='xs'] {
--_split-button-menu-gap: var(--space-1);
}
[data-split-button-menu][data-size='sm'] {
--_split-button-menu-gap: var(--space-1-5);
}
[data-split-button-menu][data-size='md'] {
--_split-button-menu-gap: var(--space-2);
}
[data-split-button-menu][data-size='lg'] {
--_split-button-menu-gap: var(--space-2-5);
}
[data-split-button-menu][data-size='xl'] {
--_split-button-menu-gap: var(--space-3);
}
/*
* Canonical separation. floating-ui's `sideOffset` is a JS number — it can't take
* a token — so when no explicit `sideOffset` is given (`data-canonical-gap`) the
* gap is a real `--space-*` length applied as a margin on the placed side. It
* follows density × scaling for free and scales with the size step above. The
* floating wrapper is `position: fixed` (a BFC), so this margin does NOT collapse
* through it. An explicit `sideOffset` drops the marker and uses floating-ui's px
* offset instead. The `--space-2` fallback covers the (unreachable) case where
* no size step set the var.
*/
[data-split-button-menu][data-canonical-gap][data-side='bottom'] {
margin-block-start: var(--_split-button-menu-gap, var(--space-2));
}
[data-split-button-menu][data-canonical-gap][data-side='top'] {
margin-block-end: var(--_split-button-menu-gap, var(--space-2));
}
[data-split-button-menu][data-canonical-gap][data-side='right'] {
margin-inline-start: var(--_split-button-menu-gap, var(--space-2));
}
[data-split-button-menu][data-canonical-gap][data-side='left'] {
margin-inline-end: var(--_split-button-menu-gap, var(--space-2));
}

@ -0,0 +1,95 @@
<script lang="ts">
import './split-button.css';
/**
* Eidos <SplitButton> — primary action + ▾ menu trigger.
*
* Pure composition (scope:['eidos']): a `<DropdownMenu>` context wraps a
* segmented `<ButtonGroup attached>` holding the consumer's
* `<SplitButton.Primary>` plus an auto-rendered ▾ `<DropdownMenu.Trigger>`.
* The trigger is rendered via the Trigger's `child` snippet as a plain
* `<Button iconOnly>` WITHOUT an explicit variant, so it inherits the
* group's variant / size / color from the ButtonGroup context — matching
* the primary automatically. The menu (`<SplitButton.Menu>` =
* `<DropdownMenu.Content>`) portals out of the group.
*
* The split-specific ARIA + the open/closed state (which rotates the
* chevron) are declared in `splitButtonMorfo`; this wrapper materialises
* them. role="group" comes from ButtonGroup; the trigger's
* aria-haspopup/aria-expanded + the menu's role come from DropdownMenu.
*/
import { ActiveEidos } from '$uix/eidos';
import { state, attachRef } from '$libs/reactive';
import { ButtonGroup } from '$uix/eidos/components/button-group';
import { Button } from '$uix/eidos/components/button';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { SvgChevron } from '$uix/eidos/components/svg';
import { setSplitButtonEidosContext } from './context';
import type { SplitButtonProps } from './types';
let {
open = $bindable(false),
variant = 'solid',
size = 'md',
color = 'primary',
disabled = false,
icon,
menuLabel,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
...menuRest
}: SplitButtonProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const triggerLabel = $derived(
menuLabel ?? eidos.langs.ts('#?components.split-button.menu-label|More actions')
);
// Capture the segmented group element so `<SplitButton.Menu>` can anchor the
// floating menu to the WHOLE split surface (matching width + a gap below it),
// not the bare ▾ trigger. The attachment forwards through ButtonGroup's
// prop-spread onto its `[data-split-button]` element. The resolved size rides
// along so the menu scales with the split instead of the DropdownMenu default.
const groupRef = state<HTMLElement | null>(null);
setSplitButtonEidosContext({
getAnchorEl: () => groupRef.current,
getSize: () => resolvedSize
});
</script>
<DropdownMenu bind:open {...menuRest}>
<ButtonGroup
{...attachRef(groupRef)}
{variant}
{size}
{color}
{disabled}
attached
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
data-split-button=""
data-state={open ? 'open' : 'closed'}
>
{@render children?.()}
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
iconOnly
icon={triggerIcon}
aria-label={triggerLabel}
data-split-button-trigger=""
/>
{/snippet}
</DropdownMenu.Trigger>
</ButtonGroup>
</DropdownMenu>
{#snippet triggerIcon()}
{#if icon}
{@render icon()}
{:else}
<SvgChevron direction="down" />
{/if}
{/snippet}

@ -0,0 +1,93 @@
import type { Snippet } from 'svelte';
import type { ButtonProps, ButtonVariant, ButtonSize } from '$uix/eidos/components/button';
import type { ButtonGroupColor } from '$uix/eidos/components/button-group';
import type {
DropdownMenuProps,
ContentProps as DropdownMenuContentProps
} from '$uix/eidos/components/dropdown-menu';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/** Visual variant propagated to the primary + ▾ trigger. Mirrors {@link ButtonVariant}. */
export type SplitButtonVariant = ButtonVariant;
/** Sizing scale propagated to both buttons. Mirrors {@link ButtonSize}. */
export type SplitButtonSize = ButtonSize;
/** Hierarchical color propagated to both buttons. */
export type SplitButtonColor = ButtonGroupColor;
/**
* Props for the eidos `<SplitButton>`.
*
* A primary action joined to a ▾ menu trigger (a segmented `<ButtonGroup>`
* of `<Button>` + `<DropdownMenu.Trigger>`). The visual props propagate to
* BOTH buttons via the ButtonGroup context; the ▾ trigger and the menu come
* from the composed `<DropdownMenu>`, so DropdownMenu provider props
* (`open`, `modal`, `onOpenChange`, …) pass through.
*
* Compose the menu with the re-exported parts:
* `<SplitButton.Primary>` + `<SplitButton.Menu>` + `<SplitButton.Item>`…
*/
export type SplitButtonProps = Omit<DropdownMenuProps, 'children'> & {
/** Visual variant propagated to both buttons. @default 'solid' */
variant?: SplitButtonVariant;
/** Sizing scale propagated to both buttons. @default 'md' */
size?: ResponsiveProp<SplitButtonSize>;
/** Hierarchical color propagated to both buttons. @default 'primary' */
color?: SplitButtonColor;
/** Disable both buttons at once. @default false */
disabled?: boolean;
/**
* Override the ▾ trigger glyph. Defaults to a down chevron that rotates
* 180° while the menu is open.
*/
icon?: Snippet;
/**
* Accessible name for the ▾ menu trigger. Defaults to a localised
* "More actions".
*/
menuLabel?: string;
/** Accessible name for the split group. Recommended. */
'aria-label'?: string;
/** Accessible name for the split group by reference. */
'aria-labelledby'?: string;
/** `<SplitButton.Primary>` + `<SplitButton.Menu>`. */
children?: Snippet;
};
/**
* Props for `<SplitButton.Primary>` — the primary action. It IS a `<Button>`
* (inherits variant/size/color from the group context); pass behaviour props
* (`onclick`, `type`, `loading`, `intent`, …) and, if needed, explicit visual
* overrides.
*/
export type SplitButtonPrimaryProps = ButtonProps;
/**
* Props for `<SplitButton.Menu>` — the floating panel. It IS a
* `<DropdownMenu.Content>` with split-button-aware defaults: it anchors to the
* whole segmented group (not the bare ▾ trigger) and sits a small gap below it
* (`sideOffset` defaults to 6). Every Content positioning prop still passes
* through and overrides those defaults.
*/
export type SplitButtonMenuProps = DropdownMenuContentProps & {
/**
* Grow the menu to at least the width of the split surface (the group),
* so the popup never reads narrower than the button it belongs to. Set
* false to let the menu size purely to its content. @default true
*/
matchAnchorWidth?: ResponsiveProp<boolean>;
};
// Menu surface re-exported from DropdownMenu (the menu IS a DropdownMenu).
export type {
ItemProps as SplitButtonItemProps,
GroupProps as SplitButtonGroupProps,
GroupHeadingProps as SplitButtonGroupHeadingProps,
SeparatorProps as SplitButtonSeparatorProps,
CheckboxItemProps as SplitButtonCheckboxItemProps,
CheckboxGroupProps as SplitButtonCheckboxGroupProps,
RadioGroupProps as SplitButtonRadioGroupProps,
RadioItemProps as SplitButtonRadioItemProps,
SubProps as SplitButtonSubProps,
SubTriggerProps as SplitButtonSubTriggerProps,
SubContentProps as SplitButtonSubContentProps
} from '$uix/eidos/components/dropdown-menu';

@ -3392,6 +3392,26 @@
--time-range-field-row-gap: var(--space-3);
--time-range-field-separator-color: var(--color-content-muted);
--time-range-field-separator-glyph: '—';
--fab-size-sm: 2.5rem;
--fab-size-md: 3.5rem;
--fab-size-lg: 4.5rem;
--fab-icon-sm: 1.25rem;
--fab-icon-md: 1.5rem;
--fab-icon-lg: 1.75rem;
--fab-offset: var(--space-4);
--fab-z: var(--z-index-sticky);
--fab-shadow: var(--shadow-overlay);
--fab-lift: 2px;
--onion-menu-trigger-lift: var(--fab-lift);
--onion-menu-trigger-shadow: var(--fab-shadow);
--onion-menu-sector-shadow: var(--shadow-raised);
--onion-menu-sector-ring-width: 2.5;
--onion-menu-sector-hover-ring-width: 2;
--onion-menu-glyph-bar-thickness: 2px;
--onion-menu-muted-opacity: 0.85;
--onion-menu-hover-ring-opacity: 0.65;
--button-group-divider-strength: 18%;
--split-button-trigger-px: var(--space-1);
}
[data-avatar] {

@ -4247,5 +4247,56 @@ export const THEME_BASE_RECIPE_TOKENS = {
'row-gap': 'var(--space-3)',
'separator-color': 'var(--color-content-muted)',
'separator-glyph': "'—'"
},
// ─────────────────────────────────────────────────────────────────────
// Fab — the FAB-specific size / icon / elevation scale. Deliberately OFF
// the control-height ladder (Material 40 / 56 / 72); the VALUES live here
// (generated to `--fab-*`), not as literals in fab.css.
// ─────────────────────────────────────────────────────────────────────
fab: {
'size-sm': '2.5rem',
'size-md': '3.5rem',
'size-lg': '4.5rem',
'icon-sm': '1.25rem',
'icon-md': '1.5rem',
'icon-lg': '1.75rem',
offset: 'var(--space-4)',
z: 'var(--z-index-sticky)',
shadow: 'var(--shadow-overlay)',
lift: '2px'
},
// ─────────────────────────────────────────────────────────────────────
// OnionMenu — the radial chrome. The geometry comes from the geometry
// engine + the fills from the colour engine; this owns the magic numbers
// that USED to be literals in onion-menu.css. The centre trigger IS a FAB,
// so its lift / shadow REFERENCE the fab tokens — they can't drift apart.
// ─────────────────────────────────────────────────────────────────────
'onion-menu': {
'trigger-lift': 'var(--fab-lift)',
'trigger-shadow': 'var(--fab-shadow)',
'sector-shadow': 'var(--shadow-raised)',
'sector-ring-width': '2.5',
'sector-hover-ring-width': '2',
'glyph-bar-thickness': '2px',
'muted-opacity': '0.85',
'hover-ring-opacity': '0.65'
},
// ─────────────────────────────────────────────────────────────────────
// ButtonGroup — composes <Group> + <Button>; owns only the segmented seam.
// The divider strength (currentColor mix) is the lone magic number.
// ─────────────────────────────────────────────────────────────────────
'button-group': {
'divider-strength': '18%'
},
// ─────────────────────────────────────────────────────────────────────
// SplitButton — composes <ButtonGroup> + <Button> + <DropdownMenu>. Owns
// only the slim ▾ tail's inline padding (a consumer-overridable knob).
// ─────────────────────────────────────────────────────────────────────
'split-button': {
'trigger-px': 'var(--space-1)'
}
} as const satisfies RecipeTokenSet;

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const buttonGroupLangs = {
label: {
es: 'Grupo de botones',
en: 'Button group'
}
} satisfies LangNode;

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const fabLangs = {
label: {
es: 'Botón de acción flotante',
en: 'Floating action button'
}
} satisfies LangNode;

@ -6,6 +6,7 @@ import { announceLangs } from './announce';
import { badgeLangs } from './badge';
import { breadcrumbLangs } from './breadcrumb';
import { buttonLangs } from './button';
import { buttonGroupLangs } from './button-group';
import { calendarLangs } from './calendar';
import { carouselLangs } from './carousel';
import { checkboxLangs } from './checkbox';
@ -26,6 +27,7 @@ import { dragDropLangs } from './drag-drop';
import { drawerLangs } from './drawer';
import { dropdownMenuLangs } from './dropdown-menu';
import { editableLangs } from './editable';
import { fabLangs } from './fab';
import { feedLangs } from './feed';
import { fieldLangs } from './field';
import { fileUploadLangs } from './file-upload';
@ -42,6 +44,7 @@ import { meterLangs } from './meter';
import { monthGridLangs } from './month-grid';
import { navigationMenuLangs } from './navigation-menu';
import { numberFieldLangs } from './number-field';
import { onionMenuLangs } from './onion-menu';
import { paginationLangs } from './pagination';
import { passwordFieldLangs } from './password-field';
import { pinInputLangs } from './pin-input';
@ -55,6 +58,7 @@ import { searchFieldLangs } from './search-field';
import { selectLangs } from './select';
import { skeletonLangs } from './skeleton';
import { sliderLangs } from './slider';
import { splitButtonLangs } from './split-button';
import { spinnerLangs } from './spinner';
import { sTextLangs } from './s-text';
import { splitterLangs } from './splitter';
@ -93,6 +97,7 @@ export const componentLangs = {
badge: badgeLangs,
breadcrumb: breadcrumbLangs,
button: buttonLangs,
'button-group': buttonGroupLangs,
calendar: calendarLangs,
carousel: carouselLangs,
checkbox: checkboxLangs,
@ -113,6 +118,7 @@ export const componentLangs = {
drawer: drawerLangs,
'dropdown-menu': dropdownMenuLangs,
editable: editableLangs,
fab: fabLangs,
feed: feedLangs,
field: fieldLangs,
'file-upload': fileUploadLangs,
@ -129,6 +135,7 @@ export const componentLangs = {
'month-grid': monthGridLangs,
'navigation-menu': navigationMenuLangs,
'number-field': numberFieldLangs,
'onion-menu': onionMenuLangs,
pagination: paginationLangs,
'password-field': passwordFieldLangs,
'pin-input': pinInputLangs,
@ -142,6 +149,7 @@ export const componentLangs = {
select: selectLangs,
skeleton: skeletonLangs,
slider: sliderLangs,
'split-button': splitButtonLangs,
spinner: spinnerLangs,
's-text': sTextLangs,
splitter: splitterLangs,

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const onionMenuLangs = {
label: {
es: 'Menú cebolla',
en: 'Onion menu'
}
} satisfies LangNode;

@ -0,0 +1,12 @@
import type { LangNode } from '$libs/langs';
export const splitButtonLangs = {
label: {
es: 'Botón dividido',
en: 'Split button'
},
'menu-label': {
es: 'Más acciones',
en: 'More actions'
}
} satisfies LangNode;

@ -0,0 +1,71 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* ButtonGroup — presentational cluster of independent action buttons.
*
* NOT a selection control: it owns no value, no pressed state, no roving
* focus. Each child <Button> stays independently Tab-focusable and fires
* its OWN `contact-activate`. The group carries no perceptual signal — per
* the book (CANON §5), a structural frame does not absorb the act's signal;
* the signal lives in each composed Button. Hence 0 events.
*
* The group's only contract is the ACCESSIBILITY grouping: `role="group"`
* plus an accessible name (`aria-label` / `aria-labelledby`) and the
* orientation hint. Everything else — variant / size / color propagation,
* the attached/segmented layout, block stretch — is VISUAL and owned by
* eidos (the recipe + the composed <Group>).
*
* scope: ['eidos'] — like Icon, there is no behaviour / state / keyboard,
* so no soma runtime. The eidos wrapper materialises the ARIA + data-attrs
* declared HERE directly; the morfo remains the single source of truth for
* the contract and the wrapper never stamps an attr this file doesn't
* declare.
*/
export const buttonGroupMorfo = {
name: 'ButtonGroup',
kebab: 'button-group',
scope: ['eidos'],
texts: {
label: '#?components.button-group.label|Button group'
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
role: 'group',
optional: false,
data: [
{
attr: 'data-orientation',
values: ['horizontal', 'vertical'],
value: v.propRef('orientation')
},
{
attr: 'data-disabled',
value: v.propRef('disabled'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
],
aria: [
{ attr: 'aria-orientation', value: v.propRef('orientation') },
{
attr: 'aria-label',
value: v.propRef('aria-label'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'aria-label' }
},
{
attr: 'aria-labelledby',
value: v.propRef('aria-labelledby'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'aria-labelledby' }
}
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,62 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* FAB — Floating Action Button. A prominent, elevated button for a screen's
* primary action: a circular icon by default, or an extended pill with a
* label. It floats over content (own `placement`, fixed) and lifts on hover.
*
* scope: ['eidos'] — the FAB composes the framework `<Button>` (chrome, intent
* colour, focus ring, icon slot, the native `<button>` + onclick) and adds only
* the FAB-specific treatment: the prominent elevation, the circular / extended
* shape, the FAB size scale, and the floating placement. This morfo declares
* the FAB's own contract — the structural identity `data-fab`, the action role,
* the accessible name — and REFERENCES, never re-stamps, Button's own ARIA
* (`role="button"`, `data-disabled`, the native disabled state).
*
* A FAB that fans out to several actions (speed-dial / FAB menu) is a separate,
* deferred feature (see README → Gaps); this morfo covers the single primary
* action only.
*/
export const fabMorfo = {
name: 'Fab',
kebab: 'fab',
scope: ['eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/button/',
texts: {
label: '#?components.fab.label|Floating action button'
},
parts: [
{
// The FAB itself. Structurally a <Button>: the same <button> carries
// `data-button` + `data-fab`. role / disabled come from Button; this
// morfo declares the FAB identity + the accessible name.
name: 'Provider',
kebab: 'provider',
archetype: 'action',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
data: [
{
attr: 'data-disabled',
value: v.propRef('disabled'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
],
aria: [
{
// Recommended on every FAB and REQUIRED for the icon-only form
// (no visible label). The extended form also keeps it as the
// authoritative name when the visible label is terse.
attr: 'aria-label',
value: v.propRef('aria-label'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'aria-label' }
}
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,167 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* OnionMenu — a trigger-agnostic radial ("onion") menu. Any trigger opens a
* ring of options around it; an option with children drills into a new
* concentric ring (the active branch derives its colour, the inner rings mute
* to a trail). It is a Menu Button by APG — the visual is radial, the semantics
* are a `role="menu"` with `menuitem`s opened by a `aria-haspopup` trigger.
*
* scope: ['soma','sema','eidos'] — soma owns the open + drill state, the radial
* keyboard navigation and focus/dismiss; eidos renders the SVG rings from the
* geometry + colour engines; sema picks up open/close from the `emerge` family
* base and commit-select from `commit` (expression: 'family-default', no pack).
*/
export const onionMenuMorfo = {
name: 'OnionMenu',
kebab: 'onion-menu',
scope: ['soma', 'sema', 'eidos'],
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
texts: {
label: '#?components.onion-menu.label|Onion menu'
},
focus: {
initial: 'first-focusable',
// Non-modal: arrow keys navigate the ring, but Tab / outside-click exit
// and close (dismissal owns that). The trigger gets focus back on close.
trap: false,
return: 'trigger',
restore: true
},
events: [
{
name: 'open',
semantic: { family: 'emerge', verb: 'open', target: v.partRef('surface'), sequence: 'pre' },
commits: { part: v.partRef('surface'), attr: 'data-state', value: 'open' }
},
{
name: 'close',
semantic: { family: 'emerge', verb: 'close', target: v.partRef('surface'), sequence: 'pre' },
commits: { part: v.partRef('surface'), attr: 'data-state', value: 'closed' }
},
{
name: 'commit-select',
semantic: {
family: 'commit',
verb: 'select',
target: v.partRef('item'),
intent: 'affirm',
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'virtual',
defaultElement: 'none',
optional: false,
data: [],
aria: []
},
{
// The trigger is consumer-supplied (any element); the soma stamps this
// contract onto it. role / haspopup / expanded come from here.
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
aria: [
{ attr: 'aria-haspopup', value: v.literal('menu') },
{ attr: 'aria-expanded', value: v.stateRef('open') },
{
attr: 'aria-controls',
value: v.partRef('surface'),
condition: { when: 'part-present', part: 'surface' },
severity: 'recommended'
}
],
keyboard: [
{ key: 'Enter', action: 'open' },
{ key: ' ', action: 'open' },
{ key: 'ArrowDown', action: 'open' }
]
},
{
// The radial surface (an SVG menu). Portals over content; the rings are
// painted inside it.
name: 'Surface',
kebab: 'surface',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
role: 'menu',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('trigger'),
condition: { when: 'part-present', part: 'trigger' },
severity: 'recommended'
}
],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'ArrowRight', action: 'next-item' },
{ key: 'ArrowLeft', action: 'prev-item' },
{ key: 'Enter', action: 'activate' },
{ key: ' ', action: 'activate' },
{ key: 'Backspace', action: 'drill-up' }
]
},
{
// One option = one annular sector. `data-depth` is the ring level,
// `data-active` the followed option in a drilled ring, `data-muted` an
// already-passed option. Branch options expose `aria-haspopup` +
// `aria-expanded`; the rest are plain `menuitem`s.
name: 'Item',
kebab: 'item',
archetype: 'item',
kind: 'public',
defaultElement: 'button',
role: 'menuitem',
optional: false,
states: ['expanded', 'collapsed'],
data: [
{ attr: 'data-depth', severity: 'optional' },
{ attr: 'data-active', severity: 'optional' },
{ attr: 'data-muted', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{
attr: 'aria-haspopup',
value: v.literal('menu'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'hasChildren' }
},
{
attr: 'aria-expanded',
value: v.stateRef('expanded'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'hasChildren' }
},
{
attr: 'aria-disabled',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,99 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* SplitButton — a primary action button joined to a menu trigger that
* opens secondary actions (e.g. "Save | ▾" → Save as…, Save a copy…).
*
* The primary button runs its own `onclick`; ONLY the ▾ trigger opens the
* menu (a single button whose whole surface opens a menu is a *menu
* button*, covered by `<DropdownMenu.Trigger endIcon={chevron}>`, not a
* split button).
*
* scope: ['eidos'] — SplitButton composes existing components and adds no
* runtime of its own: the group + segmented chrome + variant/size/color
* propagation come from <ButtonGroup attached>, the primary from <Button>,
* the ▾ + menu from <DropdownMenu>. This morfo declares the SPLIT-specific
* contract (the role="group" container, the open/closed state that drives
* the chevron rotation, and the two button parts as structural identities).
* The composed components keep their own ARIA (role="button",
* aria-haspopup/aria-expanded, aria-pressed, the menu's role="menu"); this
* morfo references — never re-stamps — those.
*/
export const splitButtonMorfo = {
name: 'SplitButton',
kebab: 'split-button',
scope: ['eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
texts: {
label: '#?components.split-button.label|Split button'
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
role: 'group',
optional: false,
data: [
{
// Reflects the menu open/closed state at the split level — drives the
// ▾ chevron rotation (and any group-level open affordance). Stamped by
// the wrapper from the bound `open` (no soma runtime for scope:['eidos']).
attr: 'data-state',
values: ['open', 'closed']
},
{
attr: 'data-disabled',
value: v.propRef('disabled'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
],
aria: [
{
attr: 'aria-label',
value: v.propRef('aria-label'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'aria-label' }
},
{
attr: 'aria-labelledby',
value: v.propRef('aria-labelledby'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'aria-labelledby' }
}
]
},
{
// The primary action. Structurally a <Button>: the same <button> carries
// `data-button` + `data-split-button-primary`. role/aria come from Button.
name: 'Primary',
kebab: 'primary',
archetype: 'action',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
data: [],
aria: []
},
{
// The ▾ menu trigger. Structurally a <DropdownMenu.Trigger> (a <Button>):
// the same <button> carries `data-button` + `data-dropdown-menu-trigger` +
// `data-split-button-trigger`. `aria-expanded` + `data-state` come from the
// DropdownMenu soma; this morfo declares only the menu-button affordance.
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
data: [],
aria: [{ attr: 'aria-haspopup', value: v.literal('menu') }]
}
]
} as const satisfies Morfo;

@ -252,6 +252,7 @@ interface MenuContentOpts
onFocusOutside: (e: FocusEvent) => void;
dir: Direction;
style: StyleProperties | null | undefined | string;
customAnchor: HTMLElement | null;
}> {}
export class MenuContentProvider {
@ -304,7 +305,10 @@ export class MenuContentProvider {
dir: opts.dir,
style: opts.style,
enabled: readableActive(() => this.provider.opts.open.current),
customAnchor: readableActive(() => null)
// Defaults to null (anchor = the registered Trigger FloatingAnchor).
// SplitButton passes the segmented group here so the menu spans the
// whole split surface instead of the bare ▾ trigger.
customAnchor: opts.customAnchor
});
// Focus

@ -0,0 +1,9 @@
// OnionMenu soma layer — the headless behaviour bridge for the radial menu.
//
// import { createOnionMenuRuntime, onionNavNext } from '$soma/components/onion-menu';
//
// The eidos root owns the open + drill state and the SVG render; this layer owns
// the perceptual emission + keyboard-action dispatch (`createOnionMenuRuntime`)
// and the pure radial focus maths (`onionNav*`).
export { createOnionMenuRuntime, onionMenuMorfo } from './onion-menu.svelte';
export { onionNavNext, onionNavPrev, onionNavFirst, type OnionRingNav } from './onion-menu-nav';

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { onionNavNext, onionNavPrev, onionNavFirst } from './onion-menu-nav';
describe('onion radial nav — wraps around the ring', () => {
const ring = { count: 4 };
it('next advances and wraps past the last back to the first', () => {
expect(onionNavNext(0, ring)).toBe(1);
expect(onionNavNext(2, ring)).toBe(3);
expect(onionNavNext(3, ring)).toBe(0);
});
it('prev retreats and wraps past the first back to the last', () => {
expect(onionNavPrev(2, ring)).toBe(1);
expect(onionNavPrev(0, ring)).toBe(3);
});
it('first is index 0', () => {
expect(onionNavFirst(ring)).toBe(0);
});
});
describe('onion radial nav — skips disabled options', () => {
const ring = { count: 4, disabled: [1, 2] };
it('next jumps over the disabled run', () => {
expect(onionNavNext(0, ring)).toBe(3);
expect(onionNavNext(3, ring)).toBe(0);
});
it('prev jumps over the disabled run', () => {
expect(onionNavPrev(3, ring)).toBe(0);
expect(onionNavPrev(0, ring)).toBe(3);
});
it('first skips a disabled leading index', () => {
expect(onionNavFirst({ count: 3, disabled: [0] })).toBe(1);
});
});
describe('onion radial nav — degenerate rings', () => {
it('returns -1 when there are no options', () => {
expect(onionNavNext(0, { count: 0 })).toBe(-1);
expect(onionNavFirst({ count: 0 })).toBe(-1);
});
it('returns -1 when every option is disabled', () => {
expect(onionNavFirst({ count: 2, disabled: [0, 1] })).toBe(-1);
expect(onionNavNext(0, { count: 2, disabled: [0, 1] })).toBe(-1);
});
it('a single enabled option is its own neighbour', () => {
expect(onionNavNext(0, { count: 1 })).toBe(0);
expect(onionNavPrev(0, { count: 1 })).toBe(0);
});
});

@ -0,0 +1,42 @@
/**
* OnionMenu radial keyboard navigation — pure index transitions within the
* focused ring. A radial menu has no ends, so `next` / `prev` wrap around, and
* disabled options are skipped. The eidos root owns the drill path + the SVG
* render and the per-ring option data; these helpers only answer "where does
* focus go next?" so the movement rule is testable in isolation.
*/
export interface OnionRingNav {
/** Number of options in the focused ring. */
readonly count: number;
/** Indices that are disabled (skipped by keyboard nav). */
readonly disabled?: readonly number[];
}
/** Walk `dir` steps from `from`, wrapping around the ring, skipping disabled. -1 if none focusable. */
function step(from: number, dir: 1 | -1, ring: OnionRingNav): number {
const { count } = ring;
if (count <= 0) return -1;
const disabled = new Set(ring.disabled ?? []);
if (disabled.size >= count) return -1;
for (let i = 1; i <= count; i++) {
const idx = (((from + dir * i) % count) + count) % count;
if (!disabled.has(idx)) return idx;
}
return -1;
}
/** The next focusable index after `from` (wraps; skips disabled). -1 if none. */
export function onionNavNext(from: number, ring: OnionRingNav): number {
return step(from, 1, ring);
}
/** The previous focusable index before `from` (wraps; skips disabled). -1 if none. */
export function onionNavPrev(from: number, ring: OnionRingNav): number {
return step(from, -1, ring);
}
/** The first focusable index in the ring (skips leading disabled). -1 if none. */
export function onionNavFirst(ring: OnionRingNav): number {
return step(-1, 1, ring);
}

@ -0,0 +1,23 @@
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { onionMenuMorfo } from '$uix/morfo/components/onion-menu';
/**
* The OnionMenu soma runtime. Unlike a compound provider, the OnionMenu's render
* is a single SVG root that already owns the open + drill state, so soma here is
* the headless BEHAVIOUR bridge rather than a parts tree:
*
* - `keydown('trigger' | 'surface', event)` dispatches against the morfo's
* keyboard contract (Enter/Space/ArrowDown open; Arrow/Enter/Backspace/Escape
* navigate) → the eidos-supplied `actions` handlers;
* - `trigger('open' | 'close' | 'commit-select', { fallbackTarget })` emits the
* perceptual signal (sound / haptic) + drives the a11y live region.
*
* The eidos root creates this with `dom` + `eventEngine` (`uix.events`) +
* `announce` + its `actions`, and drives focus / drill / dismiss itself. Pure
* radial-nav index maths live in {@link './onion-menu-nav'} (testable apart).
*/
export function createOnionMenuRuntime(sources: SomaRuntimeSources): SomaRuntime {
return createSomaRuntime(onionMenuMorfo, sources);
}
export { onionMenuMorfo };

@ -0,0 +1,496 @@
<script lang="ts">
import {
ButtonGroup,
type ButtonGroupVariant,
type ButtonGroupSize,
type ButtonGroupColor,
type ButtonGroupOrientation
} from '$uix/eidos/components/button-group';
import { Button } from '$uix/eidos/components/button';
import { compileMorfo } from '$uix/morfo';
import { buttonGroupMorfo } from '@/uix/morfo/components/button-group';
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 — every public prop is a control ────────────────────────
let variant = $state<ButtonGroupVariant>('surface');
let size = $state<ButtonGroupSize>('md');
let color = $state<ButtonGroupColor>('primary');
let orientation = $state<ButtonGroupOrientation>('horizontal');
let attached = $state(true);
let block = $state(false);
let equal = $state<'auto' | 'on' | 'off'>('auto');
let disabled = $state(false);
let ariaLabel = $state('Text alignment');
let overrideMiddle = $state(false);
// `equal` prop: 'auto' (undefined → follows attached) · 'on' · 'off'.
const equalProp = $derived(equal === 'auto' ? undefined : equal === 'on');
const variants = ['solid', 'soft', 'surface', 'outline', 'ghost', 'plain'] as const;
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
const colors = ['primary', 'secondary', 'neutral'] as const;
const orientationOptions = ['horizontal', 'vertical'] as const;
// ── Compiled morfo ─────────────────────────────────────────────────────
const compiled = compileMorfo(buttonGroupMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
$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();
});
// ── Code snippet (eidos-native — ButtonGroup has no soma layer) ────────
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { ButtonGroup } from '$uix/eidos/components/button-group';",
" import { Button } from '$uix/eidos/components/button';",
'</' + 'script>',
'',
'<ButtonGroup',
` aria-label="${ariaLabel || 'Actions'}"`,
variant !== 'solid' && ` variant="${variant}"`,
size !== 'md' && ` size="${size}"`,
color !== 'primary' && ` color="${color}"`,
orientation !== 'horizontal' && ` orientation="${orientation}"`,
!attached && ' attached={false}',
block && ' block',
equal === 'on' && ' equal',
equal === 'off' && ' equal={false}',
disabled && ' disabled',
'>',
' <Button>Left</Button>',
overrideMiddle ? ' <Button variant="solid">Center</Button>' : ' <Button>Center</Button>',
' <Button>Right</Button>',
'</ButtonGroup>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Controls · ButtonGroup</div>
<h1 data-uix-page-title>ButtonGroup</h1>
<p data-uix-page-lede>
Presentational cluster of independent action buttons — <code>role="group"</code> plus shared
visual defaults (<code>variant</code> / <code>size</code> / <code>color</code> /
<code>disabled</code>) that propagate to the child <a href="/uix/components/button"
>&lt;Button&gt;</a
>s via context. Composes <a href="/uix/components/group">&lt;Group&gt;</a> for the
segmented (<code>attached</code>) layout. Passive — 0 semantic events; each button fires its
own <code>contact-activate</code>.
</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>composes</span>Group · Button
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<ButtonGroup
{variant}
{size}
{color}
{orientation}
{attached}
{block}
equal={equalProp}
{disabled}
aria-label={ariaLabel || undefined}
>
<Button>Left</Button>
<Button variant={overrideMiddle ? 'solid' : undefined}>Center</Button>
<Button>Right</Button>
</ButtonGroup>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click a button to see its contact-activate</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>attached</span>
{String(attached)}
</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>10</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>
ButtonGroup is eidos-only — no <span data-uix-layer-badge="soma">soma</span> split. The
visual props propagate as defaults to the child Buttons; an explicit prop on a child
always wins (toggle <code>override middle</code> to see it).
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment + layout
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant <span data-uix-control-hint>propagated</span></span>
<span data-uix-chips role="radiogroup">
{#each variants as v}
<button
data-uix-chip
data-active={variant === v}
onclick={() => (variant = v)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size <span data-uix-control-hint>propagated</span></span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color <span data-uix-control-hint>hierarchy, propagated</span></span>
<span data-uix-chips role="radiogroup">
{#each colors as c}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each orientationOptions as o}
<button
data-uix-chip
data-active={orientation === o}
onclick={() => (orientation = o)}>{o}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>attached <span data-uix-control-hint>segmented</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={attached} />
<span data-uix-switch-label>{attached ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>block <span data-uix-control-hint>full width</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={block} />
<span data-uix-switch-label>{block ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>equal <span data-uix-control-hint>same width · auto when attached</span></span>
<span data-uix-chips role="radiogroup">
{#each ['auto', 'on', 'off'] as e}
<button
data-uix-chip
data-active={equal === e}
onclick={() => (equal = e as 'auto' | 'on' | 'off')}>{e}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled <span data-uix-control-hint>propagated</span></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>override middle <span data-uix-control-hint>child variant="solid" wins</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={overrideMiddle} />
<span data-uix-switch-label>{overrideMiddle ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>aria-label</span>
<input type="text" bind:value={ariaLabel} placeholder="(role=group needs a name)" />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · composes Group + Button, propagates defaults</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>
Single-root component — <code>&lt;ButtonGroup&gt;</code> takes real
<code>&lt;Button&gt;</code> children (no <code>ButtonGroup.Item</code>). The visual props
propagate as defaults to those children via an eidos context.
</p>
<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">variant</td><td class="type">'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain'</td><td class="default">'solid'</td><td>Propagated to child Buttons as their default variant.</td></tr>
<tr><td class="name">size</td><td class="type">ResponsiveProp&lt;'xs' | 'sm' | 'md' | 'lg' | 'xl'&gt;</td><td class="default">'md'</td><td>Propagated to child Buttons.</td></tr>
<tr><td class="name">color</td><td class="type">'primary' | 'secondary' | 'neutral'</td><td class="default">'primary'</td><td>Hierarchical color propagated to child Buttons. Per-button intent is set on the Button itself.</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disables every child Button at once.</td></tr>
<tr><td class="name">orientation</td><td class="type">'horizontal' | 'vertical'</td><td class="default">'horizontal'</td><td>Layout axis → <code>aria-orientation</code> + <code>data-orientation</code>.</td></tr>
<tr><td class="name">attached</td><td class="type">boolean</td><td class="default">true</td><td>Segmented: children share edges, inner corners squared, doubled borders collapsed.</td></tr>
<tr><td class="name">block</td><td class="type">boolean</td><td class="default">false</td><td>Stretch the group to fill the container inline-size; children fill it equally.</td></tr>
<tr><td class="name">equal</td><td class="type">boolean</td><td class="default">attached</td><td>Same button width. Vertical: each = the widest (content-sized). Horizontal: fills the container, split equally. Defaults ON when attached.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name. Recommended — <code>role="group"</code> needs a name.</td></tr>
<tr><td class="name">aria-labelledby</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name by reference (id of a visible label).</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 vs UIX</th></tr></thead>
<tbody>
<tr><td class="name">chakra-ui</td><td><code>&lt;ButtonGroup&gt;</code></td><td>Same shape — propagates size/variant/colorScheme + isAttached. Chakra doesn't set <code>role="group"</code>; UIX does (declared in morfo).</td></tr>
<tr><td class="name">mui</td><td><code>&lt;ButtonGroup&gt;</code></td><td>MUI sets <code>role="group"</code> + propagates via context, attached by default — same model. UIX adds <code>orientation</code> → <code>aria-orientation</code>.</td></tr>
<tr><td class="name">radix-themes</td><td>none (use <code>&lt;Flex&gt;</code>)</td><td>Radix has no ButtonGroup; <code>SegmentedControl</code> is a selection control. UIX keeps actions independent + groups them semantically.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
ButtonGroup is scope-eidos — there is no soma runtime. The morfo locks the ARIA grouping
contract (<code>role="group"</code> + <code>aria-orientation</code> + accessible name) and
the <code>data-orientation</code> / <code>data-disabled</code> attrs. The wrapper
materialises exactly what the morfo declares — nothing more. Source:
<code>src/uix/morfo/components/button-group.ts</code>.
</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 class="type">"{buttonGroupMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{buttonGroupMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{buttonGroupMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">parts.length</td><td class="default">{buttonGroupMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">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>Part</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="default">{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 buttonGroupMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{
attr: string;
value: { kind: string };
condition?: { when: string; prop?: 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</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</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})` : ''}` : '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 id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
ButtonGroup declares no semantic events. As a structural frame it carries no perceptual
signal — per CANON §5 the signal belongs to the act, not the container. Each composed
<a href="/uix/components/button">&lt;Button&gt;</a> fires its own
<code>contact-activate</code> (family <code>contact</code>); click a button in the stage
and watch the trace strip.
</p>
<div data-uix-empty-state>No Sema events declared.</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Layout (flex, gap, attached corners + border overlap, grow) is owned by the composed
<a href="/uix/components/group">&lt;Group&gt;</a> (<code>[data-group]</code>). ButtonGroup's
own CSS at <code>src/uix/eidos/components/button-group/button-group.css</code> adds only the
button-specific concerns.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-button-group]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Provider marker. Tunes Group's <code>--group-attached-overlap</code> to the Button border.</td></tr>
<tr><td class="name"><code>[data-button-group][data-orientation]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Layout axis (also drives Group's <code>data-direction</code>).</td></tr>
<tr><td class="name"><code>[data-button-group][data-disabled]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Group-level disabled (propagated to each Button).</td></tr>
<tr><td class="name"><code>[data-button-group][data-attached] [data-button]:focus-visible</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Float the focused/active button above neighbours so the ring isn't clipped.</td></tr>
<tr><td class="name"><code>[data-button-group][data-block]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Full-width stretch.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Role</td><td><code>role="group"</code> — declared in the morfo, fixed. A cluster of independent actions. For toolbar semantics (roving focus, single tab stop) use <a href="/uix/components/toolbar">&lt;Toolbar&gt;</a> instead.</td></tr>
<tr><td class="name">Label</td><td>Provide <code>aria-label</code> or <code>aria-labelledby</code> — a <code>role="group"</code> without a name is announced as an unnamed group.</td></tr>
<tr><td class="name">Orientation</td><td><code>aria-orientation</code> mirrors the <code>orientation</code> prop (horizontal / vertical).</td></tr>
<tr><td class="name">Keyboard</td><td>Each button stays independently <span data-uix-kbd>Tab</span>-focusable — no roving. The group itself is not focusable; activation is the native button keyboard (<span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span>) on each child.</td></tr>
<tr><td class="name">Disabled</td><td>The <code>disabled</code> prop propagates to every child Button (each gets <code>aria-disabled</code>) and the group sets <code>data-disabled</code>.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,416 @@
<script lang="ts">
import { Fab, type FabSize, type FabPlacement } from '$uix/eidos/components/fab';
import { SvgPlus } from '$uix/eidos/components/svg';
import { compileMorfo } from '$uix/morfo';
import { fabMorfo } from '@/uix/morfo/components/fab';
import type { ButtonVariant } from '$uix/eidos/components/button';
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 — every public prop is a control ────────────────────────
let extended = $state(false);
let size = $state<FabSize>('md');
let placement = $state<FabPlacement>('static');
let variant = $state<ButtonVariant>('solid');
let intent = $state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'>('neutral');
let color = $state<'primary' | 'secondary' | 'neutral'>('primary');
let disabled = $state(false);
let label = $state('Compose');
let count = $state(0);
const sizes = ['sm', 'md', 'lg'] as const;
const placements = ['static', 'bottom-end', 'bottom-start', 'top-end', 'top-start'] as const;
const variants = ['solid', 'soft', 'surface', 'outline', 'ghost', 'plain'] as const;
const intents = ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const;
const colors = ['primary', 'secondary', 'neutral'] as const;
// ── Compiled morfo ─────────────────────────────────────────────────────
const compiled = compileMorfo(fabMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
$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();
});
// ── Code snippet (eidos-native — no soma layer) ────────────────────────
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Fab } from '$uix/eidos/components/fab';",
" import { SvgPlus } from '$uix/eidos/components/svg';",
'</' + 'script>',
'',
'<Fab',
extended && ' extended',
size !== 'md' && ` size="${size}"`,
placement !== 'bottom-end' && ` placement="${placement}"`,
variant !== 'solid' && ` variant="${variant}"`,
intent !== 'neutral' && ` intent="${intent}"`,
color !== 'primary' && intent === 'neutral' && ` color="${color}"`,
disabled && ' disabled',
` aria-label="${extended ? label : 'Compose'}"`,
' onclick={compose}',
'>',
' {#snippet icon()}<SvgPlus />{/snippet}',
extended && ` ${label}`,
'</Fab>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Controls · Fab</div>
<h1 data-uix-page-title>Fab</h1>
<p data-uix-page-lede>
Floating Action Button — a prominent, elevated button for a screen's primary action. A
circular icon by default, or an <code>extended</code> pill with a label. It floats over
content (own <code>placement</code>, fixed) and lifts on hover. Pure composition over
<a href="/uix/components/button">&lt;Button&gt;</a> — 0 own events, interactive by 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>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>composes</span>Button
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered. The "screen" box uses `contain: layout` so a
floating FAB pins to ITS corners (it's the containing block for the fixed
FAB), instead of the whole viewport — otherwise the demo FAB would jump
over the page chrome. -->
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
style="position: relative; contain: layout; min-block-size: 300px; border-radius: var(--uix-radius-lg); overflow: hidden;"
>
<div style="padding: var(--uix-space-4); color: var(--uix-text-faint); font-size: 0.85em; max-inline-size: 30ch;">
A page. The FAB floats in {placement === 'static' ? 'flow (static)' : `the ${placement} corner`} of this box.
</div>
<Fab
{extended}
{size}
{placement}
{variant}
{intent}
{color}
{disabled}
aria-label={extended ? label : 'Compose'}
onclick={() => (count += 1)}
>
{#snippet icon()}<SvgPlus />{/snippet}
{#if extended}{label}{/if}
</Fab>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click the FAB to fire its action</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>clicks</span>
{count}
</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>8</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>
Fab is eidos-only — no <span data-uix-layer-badge="soma">soma</span> split. The visual props
forward to the composed <a href="/uix/components/button">&lt;Button&gt;</a>; the FAB owns
size, shape, elevation and placement.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · FAB treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>extended <span data-uix-control-hint>pill + label</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={extended} />
<span data-uix-switch-label>{extended ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>placement <span data-uix-control-hint>fixed; static = in-flow</span></span>
<span data-uix-chips role="radiogroup">
{#each placements as p}
<button data-uix-chip data-active={placement === p} onclick={() => (placement = p)}>{p}</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>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · forwarded to Button
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as v}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>intent</span>
<span data-uix-chips role="radiogroup">
{#each intents as i}
<button data-uix-chip data-active={intent === i} onclick={() => (intent = i)}>{i}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color <span data-uix-control-hint>under intent=neutral</span></span>
<span data-uix-chips role="radiogroup">
{#each colors as c}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>label <span data-uix-control-hint>extended only</span></span>
<input type="text" bind:value={label} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · composes Button</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>
FAB-specific props below; everything else (<code>intent</code>, <code>color</code>,
<code>shape</code>, <code>loading</code>, <code>onclick</code>, <code>aria-label</code>…)
forwards to <a href="/uix/components/button">Button</a>. Button's <code>size</code> /
<code>iconOnly</code> / <code>rounded</code> / <code>block</code> are owned by the FAB.
</p>
<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">extended</td><td class="type">boolean</td><td class="default">false</td><td>Pill with a visible label vs circular icon-only.</td></tr>
<tr><td class="name">size</td><td class="type">'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>FAB scale (≈ 40 / 56 / 72px) — its own, larger than a control.</td></tr>
<tr><td class="name">placement</td><td class="type">'bottom-end' | 'bottom-start' | 'top-end' | 'top-start' | 'static'</td><td class="default">'bottom-end'</td><td>Where it floats (fixed + safe-area). <code>static</code> = consumer positions it.</td></tr>
<tr><td class="name">offset</td><td class="type">string</td><td class="default">var(--fab-offset)</td><td>Distance from the edges when floating.</td></tr>
<tr><td class="name">icon</td><td class="type">Snippet</td><td class="default empty">—</td><td>The glyph (visible in both forms).</td></tr>
<tr><td class="name">variant</td><td class="type">ButtonVariant</td><td class="default">'solid'</td><td>Forwarded to Button (FAB defaults to the prominent solid).</td></tr>
<tr><td class="name">intent / color</td><td class="type">like Button</td><td class="default">neutral / primary</td><td>Forwarded — the screen's primary action.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name. Required for the icon-only form.</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 vs UIX</th></tr></thead>
<tbody>
<tr><td class="name">material-web</td><td><code>&lt;md-fab&gt;</code></td><td>Same model (regular + extended, sm/md/lg). UIX composes its own Button for chrome + intent palette.</td></tr>
<tr><td class="name">mui</td><td><code>&lt;Fab&gt;</code> + <code>&lt;SpeedDial&gt;</code></td><td>UIX ships the FAB; speed-dial (fan-out menu) is a deferred follow-up.</td></tr>
<tr><td class="name">ant-design</td><td><code>&lt;FloatButton&gt;</code></td><td>Both own placement. UIX drops badge/BackTop (compose Badge / out of scope).</td></tr>
<tr><td class="name">radix / ark / chakra</td><td>— (none)</td><td>Headless libs have no FAB — you compose a button + positioning, which is exactly what UIX packages here.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
scope-eidos — no soma runtime. The morfo declares the FAB's own contract: the action role,
the accessible name, and the <code>data-fab</code> structural identity (over the composed
Button). role / disabled come from Button — referenced, never re-stamped. Source:
<code>src/uix/morfo/components/fab.ts</code>.
</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 class="type">"{fabMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{fabMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{fabMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={fabMorfo.apg} target="_blank" rel="noreferrer">button ↗</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{fabMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">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>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</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
</h2>
<p data-uix-section-desc>
Fab declares no events of its own — it is interactive <strong>by composition</strong>. The
press fires <code>contact-activate</code> on the composed Button. Click the FAB and watch
the trace strip.
</p>
<div data-uix-empty-state>No Sema events declared.</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
The composed <a href="/uix/components/button">&lt;Button&gt;</a> paints the chrome;
<code>src/uix/eidos/components/fab/fab.css</code> owns the FAB treatment — elevation,
size scale, circular/extended shape, and floating placement.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-fab]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Elevation tokens + rest shadow + transition.</td></tr>
<tr><td class="name"><code>[data-fab]:hover:not([data-disabled])</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Hover lift via the <code>translate</code> property (composes with Button's press-squeeze).</td></tr>
<tr><td class="name"><code>[data-button][data-fab][data-fab-size='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>FAB diameter + glyph size (out-specifies Button).</td></tr>
<tr><td class="name"><code>[data-fab][data-extended]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Pill: auto width + label padding + gap.</td></tr>
<tr><td class="name"><code>[data-fab][data-placement='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Fixed positioning + safe-area inset.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Role</td><td>A native <code>&lt;button&gt;</code> (from Button) — <code>role="button"</code>, focusable, Enter/Space activate.</td></tr>
<tr><td class="name">Accessible name</td><td>Icon-only FAB has no visible text, so <code>aria-label</code> is required. Extended FAB names itself from the visible label (keep <code>aria-label</code> when the label is terse).</td></tr>
<tr><td class="name">Disabled</td><td><code>disabled</code> (from Button) removes it from the tab order + drops the elevation.</td></tr>
<tr><td class="name">Motion</td><td>The hover lift is suppressed under <span data-uix-kbd>prefers-reduced-motion</span>.</td></tr>
<tr><td class="name">Placement</td><td>Floating corners clear notches / home indicators via <code>env(safe-area-inset-*)</code>. A fixed FAB shouldn't obscure focusable content — keep it clear of interactive bottom bars.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,610 @@
<script lang="ts">
import {
OnionMenu,
OnionLayer,
OnionOption,
type OnionPlacement,
type OnionDisposition,
type OnionDisplay
} from '$uix/eidos/components/onion-menu';
import { SvgPlus, SvgDot, SvgCheck, SvgChevron, SvgX, SvgMinus } from '$uix/eidos/components/svg';
import { compileMorfo } from '$uix/morfo';
import { onionMenuMorfo } from '@/uix/morfo/components/onion-menu';
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);
// ── Menu state (the OnionMenu owns drilling; we bind to read it) ───────
let open = $state(true);
let drillPath = $state<number[]>([2]);
let selected = $state<number[] | null>(null);
let last = $state('—');
// ── Live props — every public knob is a control ────────────────────────
let placement = $state<OnionPlacement>('bottom-end');
let display = $state<OnionDisplay>('both');
let disposition = $state<OnionDisposition>('angular');
let gap = $state(5);
let innerRadius = $state(82);
let ringWidth = $state(66);
let fontSize = $state(13);
let width = $state(560);
let height = $state(460);
let customTrigger = $state(false);
const placements: OnionPlacement[][] = [
['top-start', 'top-center', 'top-end'],
['left-center', 'center', 'right-center'],
['bottom-start', 'bottom-center', 'bottom-end']
];
const displays: OnionDisplay[] = ['icon', 'text', 'both'];
const dispositions: OnionDisposition[] = ['angular', 'horizontal', 'vertical'];
const compiled = compileMorfo(onionMenuMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
$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 eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { OnionMenu, OnionLayer, OnionOption } from '$uix/eidos/components/onion-menu';",
'</' + 'script>',
'',
'<OnionMenu',
` placement="${placement}"`,
' bind:open bind:drillPath bind:selected',
gap !== 5 && ` gap={${gap}}`,
innerRadius !== 82 && ` innerRadius={${innerRadius}}`,
ringWidth !== 66 && ` ringWidth={${ringWidth}}`,
fontSize !== 13 && ` fontSize={${fontSize}}`,
'>',
customTrigger && ' {#snippet trigger()}<Button>Go</Button>{/snippet}',
` <OnionLayer textDisposition="${disposition}" display="${display}">`,
' <OnionOption label="Create" icon={createIcon}>',
' <OnionLayer textDisposition="vertical">',
' <OnionOption label="Doc" onSelect={pick} />',
' <OnionOption label="Folder" onSelect={pick} />',
' </OnionLayer>',
' </OnionOption>',
' <OnionOption label="Share" icon={shareIcon} onSelect={pick} />',
' </OnionLayer>',
'</OnionMenu>'
]
.filter(Boolean)
.join('\n')
);
function playTarget(part: string): HTMLElement | null {
return (stageRef?.querySelector(`[data-onion-menu-${part}]`) ??
stageRef?.querySelector('[data-onion-menu-surface]') ??
stageRef) as HTMLElement | null;
}
</script>
{#snippet iCreate()}<SvgPlus />{/snippet}
{#snippet iShare()}<SvgChevron direction="right" />{/snippet}
{#snippet iEdit()}<SvgMinus />{/snippet}
{#snippet iDoc()}<SvgChevron direction="down" />{/snippet}
{#snippet iDot()}<SvgDot />{/snippet}
{#snippet iCheck()}<SvgCheck />{/snippet}
{#snippet iX()}<SvgX />{/snippet}
{#snippet iMinus()}<SvgMinus />{/snippet}
{#snippet triggerSnippet()}<span style="color:#fff; font-weight:600; font-size:13px;">Go</span>{/snippet}
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Overlay · OnionMenu</div>
<h1 data-uix-page-title>OnionMenu</h1>
<p data-uix-page-lede>
A trigger-agnostic radial menu rendered as concentric <strong>onion</strong> rings.
<strong>Compositional</strong>: a root <code>&lt;OnionLayer&gt;</code> of
<code>&lt;OnionOption&gt;</code>s, each option nesting a <code>&lt;OnionLayer&gt;</code> for
its children. Each top option owns a palette colour; drilling derives the deeper rings and the
passed rings mute to a trail. The arc follows the placement (90 / 180 / 360). Built on two
pure engines — geometry + colour (OKLCH via <code>uix.color</code>).
</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>placements</span>9</span>
<span data-uix-meta-pill><span data-uix-meta-key>composes</span>uix.color · geometry</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href={onionMenuMorfo.apg} target="_blank" rel="noreferrer">menu-button ↗</a>
</span>
</div>
</header>
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
style="position: relative; contain: layout; min-block-size: {height}px; overflow: hidden; border-radius: var(--uix-radius-lg);"
>
<OnionMenu
{placement}
bind:open
bind:drillPath
bind:selected
{gap}
{innerRadius}
{ringWidth}
{fontSize}
{width}
{height}
trigger={customTrigger ? triggerSnippet : undefined}
>
<OnionLayer textDisposition={disposition} {display}>
<OnionOption label="Create" icon={iCreate}>
<OnionLayer textDisposition={disposition} {display}>
<OnionOption label="Doc" icon={iDoc}>
<OnionLayer textDisposition={disposition} {display}>
<OnionOption label="Blank" icon={iDot} onSelect={() => (last = 'Blank')} />
<OnionOption label="Template" icon={iDot} onSelect={() => (last = 'Template')} />
<OnionOption label="Import" icon={iDot} onSelect={() => (last = 'Import')} />
</OnionLayer>
</OnionOption>
<OnionOption label="Folder" icon={iDot} onSelect={() => (last = 'Folder')} />
<OnionOption label="Board" icon={iDot} onSelect={() => (last = 'Board')} />
<OnionOption label="Note" icon={iDot} onSelect={() => (last = 'Note')} />
</OnionLayer>
</OnionOption>
<OnionOption label="Share" icon={iShare}>
<OnionLayer textDisposition={disposition} {display}>
<OnionOption label="Link" icon={iDot} onSelect={() => (last = 'Link')} />
<OnionOption label="Email" icon={iDot} onSelect={() => (last = 'Email')} />
<OnionOption label="Embed" icon={iDot} onSelect={() => (last = 'Embed')} />
</OnionLayer>
</OnionOption>
<OnionOption label="Edit" icon={iEdit}>
<OnionLayer textDisposition={disposition} {display}>
<OnionOption label="Cut" icon={iX} onSelect={() => (last = 'Cut')} />
<OnionOption label="Copy" icon={iDot} onSelect={() => (last = 'Copy')} />
<OnionOption label="Paste" icon={iDot} onSelect={() => (last = 'Paste')} />
<OnionOption label="Color" icon={iDot}>
<OnionLayer textDisposition={disposition} {display}>
<OnionOption label="Red" icon={iDot} onSelect={() => (last = 'Red')} />
<OnionOption label="Blue" icon={iDot} onSelect={() => (last = 'Blue')} />
<OnionOption label="Green" icon={iDot} onSelect={() => (last = 'Green')} />
<OnionOption label="Gold" icon={iDot} onSelect={() => (last = 'Gold')} />
</OnionLayer>
</OnionOption>
</OnionLayer>
</OnionOption>
<OnionOption label="View" icon={iCheck}>
<OnionLayer textDisposition={disposition} {display}>
<OnionOption label="Zoom" icon={iDot} onSelect={() => (last = 'Zoom')} />
<OnionOption label="Grid" icon={iMinus} onSelect={() => (last = 'Grid')} />
</OnionLayer>
</OnionOption>
</OnionLayer>
</OnionMenu>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>open the menu, drill a branch, or fire a Sema 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>state</span>
{open ? 'open' : 'closed'} · <span data-uix-stage-trace-key>path</span>[{drillPath.join(',')}]
· <span data-uix-stage-trace-key>last</span>
{last}
</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>
OnionMenu is eidos render — the geometry + colour engines react to these props live.
Events live in the <button data-uix-inline-link onclick={() => (tab = 'sema')}>Sema</button> tab.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · OnionMenu (root) · layout + placement
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>placement <span data-uix-control-hint>→ arc 90/180/360</span></span>
<div style="display:grid; grid-template-columns:repeat(3,auto); gap:6px; width:max-content;">
{#each placements as row}
{#each row as p}
<button data-uix-chip data-active={placement === p} title={p} style="min-width:30px;" onclick={() => (placement = p)}>·</button>
{/each}
{/each}
</div>
</label>
<label data-uix-control>
<span data-uix-control-label>open</span>
<span data-uix-switch><input type="checkbox" bind:checked={open} /><span data-uix-switch-label>{open ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>innerRadius <span data-uix-control-hint>{innerRadius}px</span></span>
<input type="range" min="50" max="130" step="2" bind:value={innerRadius} />
</label>
<label data-uix-control>
<span data-uix-control-label>ringWidth <span data-uix-control-hint>{ringWidth}px</span></span>
<input type="range" min="44" max="96" step="2" bind:value={ringWidth} />
</label>
<label data-uix-control>
<span data-uix-control-label>gap <span data-uix-control-hint>{gap}px fixed</span></span>
<input type="range" min="0" max="12" step="1" bind:value={gap} />
</label>
<label data-uix-control>
<span data-uix-control-label>trigger <span data-uix-control-hint>custom vs default +</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={customTrigger} /><span data-uix-switch-label>{customTrigger ? 'custom' : 'default'}</span></span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · OnionLayer / OnionOption · content
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>display <span data-uix-control-hint>OnionOption · per option</span></span>
<span data-uix-chips role="radiogroup">
{#each displays as d}
<button data-uix-chip data-active={display === d} onclick={() => (display = d)}>{d}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>textDisposition <span data-uix-control-hint>OnionLayer · per level</span></span>
<span data-uix-chips role="radiogroup">
{#each dispositions as t}
<button data-uix-chip data-active={disposition === t} onclick={() => (disposition = t)}>{t}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>fontSize <span data-uix-control-hint>{fontSize}px (≥ sm)</span></span>
<input type="range" min="11" max="18" step="1" bind:value={fontSize} />
</label>
<label data-uix-control>
<span data-uix-control-label>width <span data-uix-control-hint>{width}px</span></span>
<input type="range" min="420" max="680" step="10" bind:value={width} />
</label>
<label data-uix-control>
<span data-uix-control-label>height <span data-uix-control-hint>{height}px</span></span>
<input type="range" min="360" max="600" step="10" bind:value={height} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>compositional · OnionMenu › OnionLayer › OnionOption</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>
Three components. <code>&lt;OnionMenu&gt;</code> holds the trigger + the root
<code>&lt;OnionLayer&gt;</code>; an <code>&lt;OnionOption&gt;</code> nests its children as a
<code>&lt;OnionLayer&gt;</code> composed inside it.
</p>
<div data-uix-subsection-head>OnionMenu</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">placement</td><td class="type">'bottom-end' | … | 'center'</td><td class="default">'bottom-end'</td><td>Anchor → arc span (90/180/360) + orientation.</td></tr>
<tr><td class="name">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Whether the rings show.</td></tr>
<tr><td class="name">drillPath <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">number[]</td><td class="default">[]</td><td>Child indices of the active drill path.</td></tr>
<tr><td class="name">selected <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">number[] | null</td><td class="default">null</td><td>Index path to the selected leaf.</td></tr>
<tr><td class="name">innerRadius / ringWidth / gap / fontSize</td><td class="type">number</td><td class="default">82 / 66 / 5 / 13</td><td>Geometry knobs (a layer can override ringWidth/gap).</td></tr>
<tr><td class="name">width / height</td><td class="type">number</td><td class="default">680 / 620</td><td>SVG viewBox size.</td></tr>
<tr><td class="name">trigger</td><td class="type">Snippet</td><td class="default empty">— (+)</td><td>Center trigger content (any element).</td></tr>
<tr><td class="name">onItemClick / onTriggerClick</td><td class="type">callback</td><td class="default empty">—</td><td>Notified on click (drill is owned internally).</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>OnionLayer</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">textDisposition</td><td class="type">'angular' | 'horizontal' | 'vertical'</td><td class="default">'angular'</td><td>Label layout for this ring.</td></tr>
<tr><td class="name">color</td><td class="type">theme scale / role</td><td class="default empty">—</td><td>Branch colour for this level — a theme scale (<code>'teal'</code>) or role (<code>'primary'</code>).</td></tr>
<tr><td class="name">display</td><td class="type">'icon' | 'text' | 'both'</td><td class="default">'both'</td><td>Default display for the level's options.</td></tr>
<tr><td class="name">ringWidth / gap</td><td class="type">number</td><td class="default empty">— (root)</td><td>Override the ring thickness / gap.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>OnionOption</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">label</td><td class="type">string</td><td class="default empty">—</td><td>Visible label + accessible name.</td></tr>
<tr><td class="name">icon</td><td class="type">Snippet</td><td class="default empty">—</td><td>Glyph (a framework <code>&lt;Svg*&gt;</code> / <code>&lt;Icon&gt;</code>).</td></tr>
<tr><td class="name">color</td><td class="type">theme scale / role</td><td class="default empty">—</td><td>Branch colour (top) / per-option override — a theme scale or role. Auto-distinct if omitted.</td></tr>
<tr><td class="name">display</td><td class="type">'icon' | 'text' | 'both'</td><td class="default empty">— (layer)</td><td>Per-option display override.</td></tr>
<tr><td class="name">disabled / onSelect</td><td class="type">boolean / () => void</td><td class="default empty">—</td><td>Disable / fire on a leaf choose.</td></tr>
<tr><td class="name">children</td><td class="type">&lt;OnionLayer&gt;</td><td class="default empty">—</td><td>A nested layer = this option's children (makes it a branch).</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
The morfo declares the menu-button contract: the trigger, the <code>role="menu"</code>
surface, the <code>menuitem</code> sectors with <code>data-depth</code> /
<code>data-active</code> / <code>data-muted</code>, and the open / close / select events.
</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 class="type">"{onionMenuMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{onionMenuMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{onionMenuMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={onionMenuMorfo.apg} target="_blank" rel="noreferrer">menu-button ↗</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{onionMenuMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{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>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each onionMenuMorfo.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 keys = partAny.keyboard ?? []}
{#if dataAttrs.length || ariaAttrs.length || keys.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</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</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 keys.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 keys as k}
<tr><td><span data-uix-kbd>{k.key === ' ' ? 'Space' : k.key}</span></td><td class="default">{k.action}</td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
<div data-uix-subsection-head>Events</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 onionMenuMorfo.events as ev}
{@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: string; target?: { kind: string; ref?: string } }}
<tr>
<td class="name">{ev.name}</td>
<td class="type">{sem.family}</td>
<td class="default">{sem.verb ?? '—'}</td>
<td class="default">{sem.sequence ?? '—'}</td>
<td class="default">{sem.intent ?? '—'}</td>
<td class="default">{sem.target?.ref ?? sem.target?.kind ?? '—'}</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
</h2>
<p data-uix-section-desc>
<code>open</code> / <code>close</code> (emerge · surface) and <code>commit-select</code>
(commit · affirm · item). Expression <code>family-default</code> — the family bases carry
the signal. The soma layer fires these on real interaction; here ▶ play emits onto the live
target.
</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 onionMenuMorfo.events as ev}
{@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: string; target?: { kind: string; ref?: string } }}
{@const part = sem.target?.ref ?? 'surface'}
<tr>
<td class="name">{ev.name}</td>
<td class="type">{sem.family}</td>
<td class="default">{sem.verb ?? '—'}</td>
<td class="default">{sem.sequence ?? '—'}</td>
<td class="default">{sem.intent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = playTarget(part);
if (!target) return;
void uix.events?.emit({
name: ev.name,
family: sem.family as never,
target,
...(sem.intent ? { intent: sem.intent as never } : {})
});
}}>▶ 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>
Fills / text / stroke are computed per sector by the colour engine and applied inline.
<code>onion-menu.css</code> owns only the structural chrome.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-onion-menu-surface]</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>The SVG menu (role="menu").</td></tr>
<tr><td class="name"><code>.onion-menu-sector</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Sector affordance + fade-in + focus ring.</td></tr>
<tr><td class="name"><code>.onion-menu-label</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Label typography (curved / flat / radial).</td></tr>
<tr><td class="name"><code>.onion-menu-icon</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Icon box inside the foreignObject.</td></tr>
<tr><td class="name"><code>.onion-menu-trigger-glyph[data-state='open']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Rotate the + → × on open.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Trigger</td><td>Any element; the soma stamps <code>role="button"</code> + <code>aria-haspopup="menu"</code> + <code>aria-expanded</code>.</td></tr>
<tr><td class="name">Surface</td><td><code>role="menu"</code>; <code>menuitem</code> sectors; branch items expose <code>aria-haspopup</code> + <code>aria-expanded</code>.</td></tr>
<tr><td class="name">Keyboard <span data-uix-tag data-kind="soma">soma</span></td><td><span data-uix-kbd>Enter</span>/<span data-uix-kbd>Space</span>/<span data-uix-kbd>↓</span> open · <span data-uix-kbd>←</span>/<span data-uix-kbd>→</span> rotate · <span data-uix-kbd>Enter</span> drill/activate · <span data-uix-kbd>Backspace</span> up · <span data-uix-kbd>Esc</span> close.</td></tr>
<tr><td class="name">Motion</td><td>Sector fade-in + the trigger glyph rotation are suppressed under <span data-uix-kbd>prefers-reduced-motion</span>.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,519 @@
<script lang="ts">
import {
SplitButton,
type SplitButtonVariant,
type SplitButtonSize,
type SplitButtonColor
} from '$uix/eidos/components/split-button';
import { compileMorfo } from '$uix/morfo';
import { splitButtonMorfo } from '@/uix/morfo/components/split-button';
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 — every public prop is a control ────────────────────────
let variant = $state<SplitButtonVariant>('solid');
let size = $state<SplitButtonSize>('md');
let color = $state<SplitButtonColor>('primary');
let disabled = $state(false);
let open = $state(false);
let ariaLabel = $state('Save options');
let primaryLabel = $state('Save');
let lastAction = $state<string>('—');
// Menu (float) props
let matchAnchorWidth = $state(true);
let menuOffsetAuto = $state(true);
let menuSideOffset = $state(8);
const variants = ['solid', 'soft', 'surface', 'outline', 'ghost', 'plain'] as const;
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
const colors = ['primary', 'secondary', 'neutral'] as const;
// ── Compiled morfo ─────────────────────────────────────────────────────
const compiled = compileMorfo(splitButtonMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
$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();
});
// ── Code snippet (eidos-native — no soma layer) ────────────────────────
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { SplitButton } from '$uix/eidos/components/split-button';",
' let open = $state(false);',
'</' + 'script>',
'',
'<SplitButton',
' bind:open',
variant !== 'solid' && ` variant="${variant}"`,
size !== 'md' && ` size="${size}"`,
color !== 'primary' && ` color="${color}"`,
disabled && ' disabled',
` aria-label="${ariaLabel || 'Actions'}"`,
'>',
` <SplitButton.Primary onclick={save}>${primaryLabel}</SplitButton.Primary>`,
' <SplitButton.Menu' +
(!matchAnchorWidth ? ' matchAnchorWidth={false}' : '') +
(!menuOffsetAuto ? ` sideOffset={${menuSideOffset}}` : '') +
'>',
' <SplitButton.Item onSelect={saveAs}>Save as…</SplitButton.Item>',
' <SplitButton.Item onSelect={saveCopy}>Save a copy</SplitButton.Item>',
' <SplitButton.Separator />',
' <SplitButton.Item data-color="threat" onSelect={discard}>Discard</SplitButton.Item>',
' </SplitButton.Menu>',
'</SplitButton>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Controls · SplitButton</div>
<h1 data-uix-page-title>SplitButton</h1>
<p data-uix-page-lede>
A primary action joined to a ▾ menu trigger — <code>Save | ▾</code> → secondary actions.
Pure composition: <a href="/uix/components/button-group">&lt;ButtonGroup attached&gt;</a> +
<a href="/uix/components/button">&lt;Button&gt;</a> +
<a href="/uix/components/dropdown-menu">&lt;DropdownMenu&gt;</a>. The ▾ trigger is
auto-rendered and inherits the group's variant / size / color; only the ▾ opens the menu.
0 own events — interactive by 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>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>composes</span>ButtonGroup · Button · DropdownMenu
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<SplitButton
{variant}
{size}
{color}
{disabled}
bind:open
aria-label={ariaLabel || undefined}
>
<SplitButton.Primary onclick={() => (lastAction = 'primary: Save')}>
{primaryLabel}
</SplitButton.Primary>
<SplitButton.Menu
{matchAnchorWidth}
sideOffset={menuOffsetAuto ? undefined : menuSideOffset}
>
<SplitButton.Item onSelect={() => (lastAction = 'Save as…')}>Save as…</SplitButton.Item>
<SplitButton.Item onSelect={() => (lastAction = 'Save a copy')}
>Save a copy</SplitButton.Item
>
<SplitButton.Separator />
<SplitButton.Item data-color="threat" onSelect={() => (lastAction = 'Discard')}
>Discard</SplitButton.Item
>
</SplitButton.Menu>
</SplitButton>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click Save, open the ▾ menu, or pick an item</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>menu</span>
{open ? 'open' : 'closed'} · <span data-uix-stage-trace-key>last</span>
{lastAction}
</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>7</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>
SplitButton is eidos-only — no <span data-uix-layer-badge="soma">soma</span> split. The
visual props propagate to BOTH the primary and the ▾ trigger via the ButtonGroup context.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant <span data-uix-control-hint>both buttons</span></span>
<span data-uix-chips role="radiogroup">
{#each variants as v}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as c}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</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>aria-label <span data-uix-control-hint>group name</span></span>
<input type="text" bind:value={ariaLabel} />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · menu (float) ·
<code>&lt;SplitButton.Menu&gt;</code>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>
matchAnchorWidth <span data-uix-control-hint>≥ surface width</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={matchAnchorWidth} />
<span data-uix-switch-label>{matchAnchorWidth ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>
sideOffset <span data-uix-control-hint>
{menuOffsetAuto ? 'canonical (--space-* per size)' : menuSideOffset + 'px gap'}
</span>
</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={menuOffsetAuto} />
<span data-uix-switch-label>{menuOffsetAuto ? 'auto' : 'manual'}</span>
</span>
</label>
{#if !menuOffsetAuto}
<label data-uix-control>
<span data-uix-control-label>
sideOffset px <span data-uix-control-hint>override</span>
</span>
<input type="range" min="0" max="24" step="1" bind:value={menuSideOffset} />
</label>
{/if}
</div>
<div data-uix-subsection-head>Demo content <span style="font-weight: normal; color: var(--uix-text-muted);">· primary label</span></div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>primary label</span>
<input type="text" bind:value={primaryLabel} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · composes ButtonGroup + Button + DropdownMenu</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>
Root props propagate to both buttons; DropdownMenu provider props (<code>open</code>,
<code>modal</code>, <code>onOpenChange</code>…) pass through. The menu surface
(<code>Menu</code> / <code>Item</code> / <code>Separator</code> / <code>Group</code> /
<code>CheckboxItem</code> / <code>RadioGroup</code> / <code>Sub</code>…) is re-exported from
<a href="/uix/components/dropdown-menu">DropdownMenu</a>.
</p>
<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">open <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">boolean</td><td class="default">false</td><td>Menu open state.</td></tr>
<tr><td class="name">variant</td><td class="type">'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain'</td><td class="default">'solid'</td><td>Propagated to both buttons.</td></tr>
<tr><td class="name">size</td><td class="type">ResponsiveProp&lt;'xs' | 'sm' | 'md' | 'lg' | 'xl'&gt;</td><td class="default">'md'</td><td>Propagated to both buttons.</td></tr>
<tr><td class="name">color</td><td class="type">'primary' | 'secondary' | 'neutral'</td><td class="default">'primary'</td><td>Propagated to both buttons.</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disables both buttons.</td></tr>
<tr><td class="name">icon</td><td class="type">Snippet</td><td class="default empty">— (chevron)</td><td>Override the ▾ glyph. The default chevron rotates while open.</td></tr>
<tr><td class="name">menuLabel</td><td class="type">string</td><td class="default">'More actions'</td><td>Accessible name for the ▾ trigger.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name for the split group.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>
<code>&lt;SplitButton.Menu&gt;</code> props <span style="font-weight: normal; color: var(--uix-text-muted);">· the float</span>
</div>
<p data-uix-section-desc>
The menu IS a <a href="/uix/components/dropdown-menu">DropdownMenu.Content</a> with
split-button-aware defaults: it anchors to the whole group, matches its width, and sits a
gap below it. Every Content positioning prop (<code>side</code>, <code>align</code>,
<code>collisionPadding</code>…) still passes through.
</p>
<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">matchAnchorWidth</td><td class="type">ResponsiveProp&lt;boolean&gt;</td><td class="default">true</td><td>Min width tracks the split surface (the anchor width); content can grow it.</td></tr>
<tr><td class="name">sideOffset</td><td class="type">number</td><td class="default">canonical</td><td>Gap below the button. Unset → resolved from the size's <code>--space-*</code> step (density × scaling aware); pass a number to override.</td></tr>
<tr><td class="name">size</td><td class="type">ResponsiveProp&lt;'xs'…'xl'&gt;</td><td class="default">split's size</td><td>Forwarded to <a href="/uix/components/dropdown-menu">DropdownMenu.Content's <code>size</code></a> (1:1 scale); defaults to the split's size, override to decouple.</td></tr>
<tr><td class="name">align</td><td class="type">'start' | 'center' | 'end'</td><td class="default">'start'</td><td>Edge of the surface the menu aligns to.</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 vs UIX</th></tr></thead>
<tbody>
<tr><td class="name">fluent-ui</td><td><code>&lt;SplitButton&gt;</code></td><td>Same model (primary + menu). UIX shares variant/size/color to both via the ButtonGroup context automatically.</td></tr>
<tr><td class="name">ant-design</td><td><code>&lt;Dropdown.Button&gt;</code></td><td>Same split. UIX's menu is the full DropdownMenu (groups/checkbox/radio/submenus), not a flat list.</td></tr>
<tr><td class="name">mui</td><td>ButtonGroup + Menu recipe</td><td>MUI documents it as a recipe; UIX ships it as a component with the chevron-rotates affordance.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>
scope-eidos — no soma runtime. The morfo declares the split-specific contract: the
<code>role="group"</code> provider, the <code>data-state</code> that rotates the chevron,
and the Primary / Trigger parts (structural identities over the composed Button /
DropdownMenu.Trigger). Source: <code>src/uix/morfo/components/split-button.ts</code>.
</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 class="type">"{splitButtonMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{splitButtonMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{splitButtonMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={splitButtonMorfo.apg} target="_blank" rel="noreferrer">menu-button ↗</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{splitButtonMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">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>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each splitButtonMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{
attr: string;
value: { kind: string };
condition?: { when: string; prop?: 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</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</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})` : ''}` : '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 id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
SplitButton declares no events of its own — it is interactive <strong>by composition</strong>.
The primary fires <code>contact-activate</code> (Button), the ▾ fires
<code>emerge.open</code>/<code>close</code> (DropdownMenu), and each item fires
<code>commit-select</code> (DropdownMenu). Exercise the stage and watch the trace strip.
</p>
<div data-uix-empty-state>No Sema events declared.</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Layout + the segmented seam come from <a href="/uix/components/button-group">&lt;ButtonGroup attached&gt;</a>;
Button + DropdownMenu paint the buttons. SplitButton's own CSS at
<code>src/uix/eidos/components/split-button/split-button.css</code> owns the
split-specific affordances: the ▾ rotates open and stays a slim tail, and the menu
tracks the surface width, scales with <code>size</code>, and sits a canonical gap below.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-split-button][data-state='open']</code></td><td><span data-uix-tag data-kind="morfo">morfo</span></td><td>Group open state (stamped from `open`).</td></tr>
<tr><td class="name"><code>… [data-split-button-trigger] [data-svg='chevron']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Rotate the ▾ chevron 180° while the menu is open.</td></tr>
<tr><td class="name"><code>[data-split-button-trigger][data-icon-only]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Slim the ▾ to glyph + snug padding (not a square).</td></tr>
<tr><td class="name"><code>[data-dropdown-menu-content][data-split-button-menu]</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td><code>min-inline-size</code> = the anchor (surface) width; content grows it.</td></tr>
<tr><td class="name"><code>[data-split-button-menu][data-size='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Gap step per size (font / height / padding scale come from <a href="/uix/components/dropdown-menu">DropdownMenu's own size axis</a>).</td></tr>
<tr><td class="name"><code>[data-split-button-menu][data-canonical-gap][data-side='…']</code></td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Canonical separation: a <code>--space-*</code> margin on the placed side.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Group</td><td><code>role="group"</code> (from ButtonGroup) + <code>aria-label</code> — names the split as a unit.</td></tr>
<tr><td class="name">Primary</td><td>A native <code>&lt;button&gt;</code> (from Button) — its own action, independently Tab-focusable.</td></tr>
<tr><td class="name">Menu trigger</td><td>Follows the APG <a href={splitButtonMorfo.apg} target="_blank" rel="noreferrer">Menu Button</a> (from DropdownMenu): <code>aria-haspopup="menu"</code> + <code>aria-expanded</code>, opens with Enter/Space/ArrowDown, names itself via <code>menuLabel</code>.</td></tr>
<tr><td class="name">Menu</td><td><code>role="menu"</code> with arrow-key navigation, typeahead, Escape-to-close, focus return to the ▾ (all from DropdownMenu).</td></tr>
<tr><td class="name">Keyboard</td><td><span data-uix-kbd>Tab</span> moves between the primary and the ▾ (no roving); each opens / activates with its native keys.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.