diff --git a/src/uix/eidos/components/fab/fab.css b/src/uix/eidos/components/fab/fab.css
index acabbb79e..67f64de6b 100644
--- a/src/uix/eidos/components/fab/fab.css
+++ b/src/uix/eidos/components/fab/fab.css
@@ -54,6 +54,12 @@
/* ── Size (diameter + glyph) ─────────────────────────────────────────── */
+[data-button][data-fab][data-fab-size='xs'] {
+ block-size: var(--fab-size-xs);
+ /* Below the Button's touch-target floor — the mini FAB owns its diameter. */
+ min-block-size: var(--fab-size-xs);
+ font-size: var(--fab-icon-xs);
+}
[data-button][data-fab][data-fab-size='sm'] {
block-size: var(--fab-size-sm);
font-size: var(--fab-icon-sm);
@@ -70,6 +76,10 @@
/* 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='xs']:not([data-extended]) {
+ inline-size: var(--fab-size-xs);
+ min-inline-size: var(--fab-size-xs);
+}
[data-button][data-fab][data-fab-size='sm']:not([data-extended]) {
inline-size: var(--fab-size-sm);
}
diff --git a/src/uix/eidos/components/fab/types.ts b/src/uix/eidos/components/fab/types.ts
index 7090dd3e2..19b99d51e 100644
--- a/src/uix/eidos/components/fab/types.ts
+++ b/src/uix/eidos/components/fab/types.ts
@@ -3,11 +3,11 @@ 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.
+ * FAB size scale. Deliberately its OWN scale (not Button's `xs…xl`): a FAB is a
+ * prominent target — `xs` ≈ 30px (a mini FAB, e.g. speed-dial actions), `sm` ≈
+ * 40px (the small FAB), `md` ≈ 56px (the canonical FAB), `lg` ≈ 72px.
*/
-export type FabSize = 'sm' | 'md' | 'lg';
+export type FabSize = 'xs' | 'sm' | 'md' | 'lg';
/**
* Floating placement around the viewport. `static` opts out of the fixed
diff --git a/src/uix/eidos/components/menu-dial/README.md b/src/uix/eidos/components/menu-dial/README.md
new file mode 100644
index 000000000..86cea6ce4
--- /dev/null
+++ b/src/uix/eidos/components/menu-dial/README.md
@@ -0,0 +1,93 @@
+# MenuDial
+
+A **speed dial**: a FAB that fans out a line — or a placement-driven arc — of
+action mini-FABs. The exact feature `fabMorfo` defers to. Flat (no nesting) — for
+a few quick actions; the concentric / nested radial case is **OnionMenu**'s. By
+APG it's a Menu Button.
+
+```svelte
+
+
+
+
+
+ {#snippet icon()}{/snippet}
+
+
+ {#snippet icon()}{/snippet}
+
+
+ {#snippet icon()}{/snippet}
+
+
+```
+
+## Composition
+
+The dial is **compositional** (`` children, never a `model`
+array) and composes the framework's primitives — zero re-invented chrome:
+
+- the **trigger IS a ``** (elevation, intent colour, the placement);
+- each **action IS a ``** too — one size smaller (the mini FAB), to which the
+ dial adds the `menuitem` role and the label.
+
+The actions render themselves (no SVG geometry, unlike OnionMenu — `linear` is
+HTML flex, `arc` is absolute placement via CSS `cos()`/`sin()`) and read the dial
+config via context; the root owns the open state, lays the fan out by `layout` +
+`direction` + `placement`, and drives the behaviour by querying the action elements.
+
+## Props
+
+**``** — `layout` (`linear`/`arc`) · `direction` (`up`/`down`/`left`/`right`
+— the linear axis, and the arc opening on `static`) · `radius` (arc radius override) ·
+`openOn` (`click`/`hover`) · `placement` (one of the **9 zones** — corner / edge /
+centre — or `static`; sets the arc span 90/180/360°) ·
+`size` (FAB size — actions are one step smaller) · `labels`
+(`tooltip`/`inline`/`none`) · `backdrop` · `stagger` · `open` (bindable) ·
+`variant`/`color`/`intent` (trigger) · `icon`/`openIcon` (snippets — omit for the
+default `+`→`×` morph) · `aria-label` · `onOpenChange`.
+
+**``** — `label` (the name + tooltip/pill) · `icon` · `onSelect`
+· `disabled` · `variant`/`color`/`intent`.
+
+## Layers
+
+- **morfo** (`$uix/morfo/components/menu-dial`) — Menu Button (APG); parts
+ `provider`/`trigger`/`list`/`action`; events `open`/`close` (emerge) +
+ `commit-select` (commit · affirm); focus policy (non-modal, return to trigger).
+- **soma** (`$soma/components/menu-dial`) — `createMenuDialRuntime` (emission +
+ keyboard dispatch) + the pure linear `dialNav*` maths (testable apart).
+- **eidos** (here) — composes Fab (trigger + actions) + Tooltip (each action's
+ name); the recipe owns the placement, the fan layout (linear + arc), the
+ open/close + stagger transition and the glyph (NOT the label — Tooltip owns it).
+
+## Keyboard & a11y
+
+Opened from the trigger (`Enter`/`Space`/any Arrow), focus moves into the fan.
+Within it: `↑↓`/`←→` move (wrap, skip disabled), `Home`/`End` jump,
+`Enter`/`Space` activate (native button), `Escape` closes. Outside-click and
+Tab-out dismiss (non-modal); focus returns to the trigger. The fan is `role="menu"`,
+the actions `role="menuitem"`, the trigger `aria-haspopup="menu"` + `aria-expanded`.
+
+## Decisions
+
+- **Two layouts** — `linear` (plain flex) and `arc`. The arc mirrors OnionMenu's
+ `onionArc` over the **9 zones**: **90°** in a corner, **180°** on an edge, **360°**
+ in the centre (a full ring), opening into the free space; one ring only (the
+ concentric / nested radial case stays OnionMenu's). The radius auto-scales with
+ the action count + span so the mini-FABs never overlap.
+- **Compositional, compose primitives** — actions are `` children,
+ the trigger a ``, each action a one-step-smaller `` (the mini FAB), and
+ each action's **name is a composed ``** — Floating-UI positioned +
+ portaled, so it's collision-corrected and always on top (never a hand-rolled
+ label). The `side` is computed radial-outward per action.
+- **`labels` is a prop** — `tooltip` (default, the Tooltip floats on hover/focus),
+ `inline` (the Tooltip forced open via `open`), or `none` (sr-only name only).
+- **Glyph state via the FRAME** — the trigger button owns its own `data-state`
+ (Button press/loading), so the `+`→`×` rotation keys off `[data-menu-dial][data-state='open']`.
+- **Double signal, noted** — composing `` (→ Button) means an action click emits
+ the button's `contact-activate` (tap) AND the dial's `commit-select` (affirm). If
+ the layered feedback is too much, a sema cascade can mute the action's contact.
diff --git a/src/uix/eidos/components/menu-dial/context.svelte.ts b/src/uix/eidos/components/menu-dial/context.svelte.ts
new file mode 100644
index 000000000..e780266a7
--- /dev/null
+++ b/src/uix/eidos/components/menu-dial/context.svelte.ts
@@ -0,0 +1,39 @@
+import { getContext, setContext } from 'svelte';
+import type { FabSize } from '$uix/eidos/components/fab';
+import type { MenuDialDirection, MenuDialLabels } from './types';
+
+/**
+ * Context the root `` provides to its ``s. The actions
+ * render themselves (HTML mini-FABs) — unlike OnionMenu's central SVG paint — so
+ * they only need the shared config (direction / labels / size / stagger order)
+ * plus the `select` callback. The root drives focus + keyboard by querying the
+ * action elements in the DOM, so no registration tree is needed.
+ */
+export interface MenuDialContext {
+ /** Which way the fan opens (drives the action's label side + slide axis). */
+ readonly direction: MenuDialDirection;
+ /** How action names are shown. */
+ readonly labels: MenuDialLabels;
+ /** FAB size for the action mini-FABs — one FAB step below the trigger. */
+ readonly actionSize: FabSize;
+ /** Whether actions stagger in/out. */
+ readonly stagger: boolean;
+ /** Monotonic index assigned per action at init — drives the stagger order. */
+ nextIndex(): number;
+ /**
+ * The Floating-UI `side` the action's label (a composed ``) should
+ * prefer — radial-outward in `arc`, perpendicular to the fan in `linear`. The
+ * Tooltip's own `avoidCollisions` corrects it against the viewport.
+ */
+ labelSide(index: number): 'top' | 'right' | 'bottom' | 'left';
+ /** Choose an action: emit `commit-select` on its element, run `onSelect`, close. */
+ select(el: HTMLElement | null, onSelect?: () => void): void;
+}
+
+const KEY = Symbol('uix.menu-dial');
+
+export const setMenuDialContext = (ctx: MenuDialContext): void => {
+ setContext(KEY, ctx);
+};
+
+export const getMenuDialContext = (): MenuDialContext | undefined => getContext(KEY);
diff --git a/src/uix/eidos/components/menu-dial/index.ts b/src/uix/eidos/components/menu-dial/index.ts
new file mode 100644
index 000000000..6cf9d4f25
--- /dev/null
+++ b/src/uix/eidos/components/menu-dial/index.ts
@@ -0,0 +1,35 @@
+// MenuDial — eidos speed dial. A FAB that fans out a line or arc of actions.
+//
+// import { MenuDial } from '$uix/eidos/components/menu-dial';
+//
+//
+// {#snippet icon()}{/snippet}
+//
+// {#snippet icon()}{/snippet}
+//
+//
+//
+//
+// The trigger is a composed ; each action a composed . The root
+// owns open + the linear keyboard nav / focus / dismiss / perceptual emission;
+// the actions render themselves and read the dial config via context.
+import MenuDialRoot from './menu-dial.svelte';
+import Action from './menu-dial-action.svelte';
+
+type MenuDialComponent = typeof MenuDialRoot & { Action: typeof Action };
+
+const MenuDial = MenuDialRoot as MenuDialComponent;
+MenuDial.Action = Action;
+
+export { MenuDial };
+export { MenuDial as MenuDialRoot, Action as MenuDialAction };
+
+export type {
+ MenuDialProps,
+ MenuDialActionProps,
+ MenuDialDirection,
+ MenuDialLayout,
+ MenuDialLabels,
+ MenuDialOpenOn,
+ MenuDialPlacement
+} from './types';
diff --git a/src/uix/eidos/components/menu-dial/menu-dial-action.svelte b/src/uix/eidos/components/menu-dial/menu-dial-action.svelte
new file mode 100644
index 000000000..a27ef965d
--- /dev/null
+++ b/src/uix/eidos/components/menu-dial/menu-dial-action.svelte
@@ -0,0 +1,72 @@
+
+
+{#if dial}
+
+
+ {#snippet child({ props }: { props: Record })}
+
+
+ {
+ if (!disabled) dial.select(e.currentTarget as HTMLElement, onSelect);
+ }}
+ />
+
+ {/snippet}
+
+ {#if showLabel}
+
+
+ {label}
+
+
+ {/if}
+
+{/if}
+
+{#snippet glyph()}
+ {#if icon}{@render icon()}{:else}{@render children?.()}{/if}
+{/snippet}
diff --git a/src/uix/eidos/components/menu-dial/menu-dial.css b/src/uix/eidos/components/menu-dial/menu-dial.css
new file mode 100644
index 000000000..a74ddfa62
--- /dev/null
+++ b/src/uix/eidos/components/menu-dial/menu-dial.css
@@ -0,0 +1,331 @@
+/*
+ * MenuDial (speed dial) recipe.
+ *
+ * The trigger is a composed and each action a composed ; this
+ * recipe owns only the dial-specific chrome: the floating placement, the fan
+ * layout (`linear` plain-flex per `data-direction`, or `arc` — actions placed on
+ * a circle via cos/sin, the span + opening following `data-placement`), the
+ * open/close + stagger transition, the action elevation, the labels (tooltip /
+ * inline) and the `+`→`×` trigger glyph. No SVG.
+ *
+ * [data-menu-dial] → frame (placement + fan layout)
+ * [data-menu-dial-trigger] → the trigger 's button
+ * [data-menu-dial-list] → the actions container (role="menu")
+ * [data-menu-dial-action] → an action mini-FAB (role="menuitem")
+ */
+
+[data-menu-dial] {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--menu-dial-gap, var(--space-3));
+
+ /* Floating offset from the viewport edges (mirrors the FAB). */
+ --menu-dial-offset: var(--space-5);
+ /* Per-action stagger step. */
+ --menu-dial-stagger-step: 28ms;
+ /* Scrim behind the open fan. */
+ --menu-dial-backdrop-bg: color-mix(in oklab, var(--primitive-neutral-12) 40%, transparent);
+}
+
+/* ── Fan layout per direction ────────────────────────────────────────── */
+
+[data-menu-dial][data-direction='up'],
+[data-menu-dial][data-direction='up'] [data-menu-dial-list] {
+ flex-direction: column-reverse;
+}
+[data-menu-dial][data-direction='down'],
+[data-menu-dial][data-direction='down'] [data-menu-dial-list] {
+ flex-direction: column;
+}
+[data-menu-dial][data-direction='left'],
+[data-menu-dial][data-direction='left'] [data-menu-dial-list] {
+ flex-direction: row-reverse;
+}
+[data-menu-dial][data-direction='right'],
+[data-menu-dial][data-direction='right'] [data-menu-dial-list] {
+ flex-direction: row;
+}
+
+[data-menu-dial-list] {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--menu-dial-gap, var(--space-3));
+}
+
+/* ── Floating placement (fixed to the viewport) — the 9 zones ──────────── */
+
+[data-menu-dial][data-placement='bottom-end'],
+[data-menu-dial][data-placement='bottom-start'],
+[data-menu-dial][data-placement='bottom-center'],
+[data-menu-dial][data-placement='top-end'],
+[data-menu-dial][data-placement='top-start'],
+[data-menu-dial][data-placement='top-center'],
+[data-menu-dial][data-placement='left-center'],
+[data-menu-dial][data-placement='right-center'],
+[data-menu-dial][data-placement='center'] {
+ position: fixed;
+ z-index: var(--z-index-sticky, 1100);
+}
+/* Edges — centred on the free axis. Centre — fully centred. */
+[data-menu-dial][data-placement='top-center'] {
+ inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
+ inset-inline-start: 50%;
+ translate: -50% 0;
+}
+[data-menu-dial][data-placement='bottom-center'] {
+ inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
+ inset-inline-start: 50%;
+ translate: -50% 0;
+}
+[data-menu-dial][data-placement='left-center'] {
+ inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
+ inset-block-start: 50%;
+ translate: 0 -50%;
+}
+[data-menu-dial][data-placement='right-center'] {
+ inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
+ inset-block-start: 50%;
+ translate: 0 -50%;
+}
+[data-menu-dial][data-placement='center'] {
+ inset-block-start: 50%;
+ inset-inline-start: 50%;
+ translate: -50% -50%;
+}
+[data-menu-dial][data-placement='bottom-end'] {
+ inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
+ inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
+}
+[data-menu-dial][data-placement='bottom-start'] {
+ inset-block-end: calc(var(--menu-dial-offset) + env(safe-area-inset-bottom, 0px));
+ inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
+}
+[data-menu-dial][data-placement='top-end'] {
+ inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
+ inset-inline-end: calc(var(--menu-dial-offset) + env(safe-area-inset-right, 0px));
+}
+[data-menu-dial][data-placement='top-start'] {
+ inset-block-start: calc(var(--menu-dial-offset) + env(safe-area-inset-top, 0px));
+ inset-inline-start: calc(var(--menu-dial-offset) + env(safe-area-inset-left, 0px));
+}
+
+/* ── Actions: open / close + stagger ─────────────────────────────────── */
+
+/* The action IS a `` — elevation, circular shape and hover lift come from
+ the FAB recipe; this recipe only owns the wrapper layout + the open/close. */
+.menu-dial-action {
+ /* Wraps ONLY the button so it stays on the trigger's centre axis; the label
+ is absolutely positioned beside it (both tooltip AND inline), so it never
+ pushes the button off-centre. */
+ position: relative;
+ display: inline-flex;
+ transition:
+ opacity var(--duration-fast) var(--ease-out, ease-out),
+ transform var(--duration-fast) var(--ease-out, ease-out);
+}
+
+[data-menu-dial-list][data-state='closed'] .menu-dial-action {
+ opacity: 0;
+ transform: scale(0.6);
+ visibility: hidden;
+ pointer-events: none;
+ /* hide only AFTER the out-transition has played */
+ transition:
+ opacity var(--duration-fast) var(--ease-out, ease-out),
+ transform var(--duration-fast) var(--ease-out, ease-out),
+ visibility 0s linear var(--duration-fast, 150ms);
+}
+[data-menu-dial-list][data-state='open'] .menu-dial-action {
+ opacity: 1;
+ transform: none;
+ visibility: visible;
+}
+
+/* Stagger — each action offset from the trigger by its index. */
+[data-menu-dial][data-stagger] [data-menu-dial-list][data-state='open'] .menu-dial-action {
+ transition-delay: calc(var(--menu-dial-i, 0) * var(--menu-dial-stagger-step));
+}
+
+/* Labels — the action's name is a composed (`menu-dial-action.svelte`),
+ positioned + collision-corrected by Floating UI and portaled (always on top).
+ This recipe no longer styles or positions any label. */
+
+/* ── Arc layout (single arc, opening per placement) ──────────────────────
+ The list overlays the trigger; each action is absolutely placed on a circle
+ of radius `--menu-dial-radius` at an angle spread across the arc. Polar frame:
+ 0° = up, clockwise — mirrors the OnionMenu geometry (`onionArc` / `polar`). */
+
+/* Containing block for the absolute list (floating placements are already `fixed`). */
+[data-menu-dial][data-layout='arc'][data-placement='static'] {
+ position: relative;
+}
+
+[data-menu-dial][data-layout='arc'] [data-menu-dial-list] {
+ position: absolute;
+ inset: 0;
+ display: block;
+ /* The list overlays the trigger — let clicks pass through to it; only the
+ open actions (below) re-capture the pointer. */
+ pointer-events: none;
+}
+[data-menu-dial][data-layout='arc'] [data-menu-dial-list][data-state='open'] .menu-dial-action {
+ pointer-events: auto;
+}
+
+/* Per-size diameters (trigger + its one-step-smaller action) feed the radius. */
+[data-menu-dial][data-layout='arc'][data-size='xs'] {
+ --_dial-trigger-size: var(--fab-size-xs);
+ --_dial-action-size: var(--fab-size-xs);
+}
+[data-menu-dial][data-layout='arc'][data-size='sm'] {
+ --_dial-trigger-size: var(--fab-size-sm);
+ --_dial-action-size: var(--fab-size-xs);
+}
+[data-menu-dial][data-layout='arc'][data-size='md'] {
+ --_dial-trigger-size: var(--fab-size-md);
+ --_dial-action-size: var(--fab-size-sm);
+}
+[data-menu-dial][data-layout='arc'][data-size='lg'] {
+ --_dial-trigger-size: var(--fab-size-lg);
+ --_dial-action-size: var(--fab-size-md);
+}
+
+/* Arc opening per placement — span + start (deg) into the free space.
+ Corners → quarter (90°); static → a half (180°) oriented by `direction`. */
+[data-menu-dial][data-layout='arc'][data-placement='bottom-end'] {
+ --menu-dial-arc-start: 270deg; /* up-left */
+ --menu-dial-arc-span: 90deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='bottom-start'] {
+ --menu-dial-arc-start: 0deg; /* up-right */
+ --menu-dial-arc-span: 90deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='top-end'] {
+ --menu-dial-arc-start: 180deg; /* down-left */
+ --menu-dial-arc-span: 90deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='top-start'] {
+ --menu-dial-arc-start: 90deg; /* down-right */
+ --menu-dial-arc-span: 90deg;
+}
+/* edges → half (180°) opening away from the anchored edge. */
+[data-menu-dial][data-layout='arc'][data-placement='top-center'] {
+ --menu-dial-arc-start: 90deg; /* opens down */
+ --menu-dial-arc-span: 180deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='bottom-center'] {
+ --menu-dial-arc-start: 270deg; /* opens up */
+ --menu-dial-arc-span: 180deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='left-center'] {
+ --menu-dial-arc-start: 0deg; /* opens right */
+ --menu-dial-arc-span: 180deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='right-center'] {
+ --menu-dial-arc-start: 180deg; /* opens left */
+ --menu-dial-arc-span: 180deg;
+}
+/* centre → full ring (360°): the actions distribute around the whole circle. */
+[data-menu-dial][data-layout='arc'][data-placement='center'] {
+ --menu-dial-arc-start: 0deg;
+ --menu-dial-arc-span: 360deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='up'] {
+ --menu-dial-arc-start: 270deg; /* opens up */
+ --menu-dial-arc-span: 180deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='down'] {
+ --menu-dial-arc-start: 90deg; /* opens down */
+ --menu-dial-arc-span: 180deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='left'] {
+ --menu-dial-arc-start: 180deg; /* opens left */
+ --menu-dial-arc-span: 180deg;
+}
+[data-menu-dial][data-layout='arc'][data-placement='static'][data-direction='right'] {
+ --menu-dial-arc-start: 0deg; /* opens right */
+ --menu-dial-arc-span: 180deg;
+}
+
+/* Radius: clear the trigger, AND spread far enough that adjacent actions
+ (chord = 2R·sin(span/2N)) don't overlap — whichever is larger. */
+[data-menu-dial][data-layout='arc'] {
+ --_dial-n: max(var(--menu-dial-count, 1), 1);
+ --_dial-r-clear: calc((var(--_dial-trigger-size) + var(--_dial-action-size)) / 2 + var(--space-3));
+ --_dial-r-spread: calc(
+ (var(--_dial-action-size) + var(--space-2)) /
+ (2 * sin(var(--menu-dial-arc-span, 90deg) / (2 * max(var(--_dial-n), 2))))
+ );
+ --menu-dial-radius: max(var(--_dial-r-clear), var(--_dial-r-spread));
+}
+
+/* Place each action on the arc. angle_i = start + span·(i + 0.5)/N; the polar
+ frame (0° = up) maps to the math frame by −90°, then cos/sin to x/y. */
+[data-menu-dial][data-layout='arc'] .menu-dial-action {
+ position: absolute;
+ inset-inline-start: 50%;
+ inset-block-start: 50%;
+ --_a: calc(
+ var(--menu-dial-arc-start, 270deg) +
+ var(--menu-dial-arc-span, 90deg) * (var(--menu-dial-i, 0) + 0.5) / var(--_dial-n)
+ );
+ --_ac: calc(var(--_a) - 90deg);
+ translate: calc(-50% + var(--menu-dial-radius) * cos(var(--_ac)))
+ calc(-50% + var(--menu-dial-radius) * sin(var(--_ac)));
+}
+
+/* ── Trigger glyph (`+`→`×`) ──────────────────────────────────────────── */
+
+.menu-dial-glyph {
+ position: relative;
+ inline-size: 1em;
+ block-size: 1em;
+ transition: transform var(--duration-normal, 300ms) var(--ease-default);
+}
+.menu-dial-glyph::before,
+.menu-dial-glyph::after {
+ content: '';
+ position: absolute;
+ inset-inline: 0;
+ inset-block-start: 50%;
+ block-size: 2px;
+ margin-block-start: -1px;
+ border-radius: var(--radius-full, 1px);
+ background: currentColor;
+}
+.menu-dial-glyph::after {
+ transform: rotate(90deg);
+}
+/* Use the FRAME's state, not the trigger button's — Button owns its own
+ `data-state` (press / loading), which would shadow ours on the trigger. */
+[data-menu-dial][data-state='open'] .menu-dial-glyph {
+ transform: rotate(45deg);
+}
+
+/* ── Backdrop ─────────────────────────────────────────────────────────── */
+
+[data-menu-dial-backdrop] {
+ position: fixed;
+ inset: 0;
+ z-index: calc(var(--z-index-sticky, 1100) - 1);
+ background: var(--menu-dial-backdrop-bg);
+ animation: menu-dial-backdrop-in var(--duration-fast, 150ms) var(--ease-out, ease-out) both;
+}
+
+@keyframes menu-dial-backdrop-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .menu-dial-action,
+ .menu-dial-glyph,
+ [data-menu-dial-backdrop] {
+ transition: none;
+ animation: none;
+ }
+}
diff --git a/src/uix/eidos/components/menu-dial/menu-dial.svelte b/src/uix/eidos/components/menu-dial/menu-dial.svelte
new file mode 100644
index 000000000..7ecf93027
--- /dev/null
+++ b/src/uix/eidos/components/menu-dial/menu-dial.svelte
@@ -0,0 +1,348 @@
+
+
+{#if backdrop && open}
+
+
closeMenu(false)}>
+{/if}
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
+{#snippet triggerIcon()}
+ {#if icon}
+ {@render (open && openIcon ? openIcon : icon)()}
+ {:else}
+
+ {/if}
+{/snippet}
diff --git a/src/uix/eidos/components/menu-dial/types.ts b/src/uix/eidos/components/menu-dial/types.ts
new file mode 100644
index 000000000..83d3f6d70
--- /dev/null
+++ b/src/uix/eidos/components/menu-dial/types.ts
@@ -0,0 +1,117 @@
+import type { Snippet } from 'svelte';
+import type { ResponsiveProp } from '$uix/eidos/lib/types';
+import type { FabSize, FabProps } from '$uix/eidos/components/fab';
+
+/**
+ * Where the dial sits — the 9 zones (mirroring OnionMenu): the 4 corners, the 4
+ * edge-middles and the centre, plus `static` (no fixed positioning — you place
+ * it). In `layout="arc"` the zone also sets the arc span + opening: 90° in a
+ * corner, 180° on an edge, **360° in the centre** (the actions ring the trigger).
+ */
+export type MenuDialPlacement =
+ | 'top-start'
+ | 'top-center'
+ | 'top-end'
+ | 'left-center'
+ | 'center'
+ | 'right-center'
+ | 'bottom-start'
+ | 'bottom-center'
+ | 'bottom-end'
+ | 'static';
+
+/** The trigger / action chrome props, reused from the FAB (which reuses Button). */
+type DialVariant = FabProps['variant'];
+type DialColor = FabProps['color'];
+type DialIntent = FabProps['intent'];
+
+/** Which way the fan opens from the trigger. */
+export type MenuDialDirection = 'up' | 'down' | 'left' | 'right';
+
+/**
+ * Fan geometry. `linear` stacks the actions in a straight line (per `direction`).
+ * `arc` fans them on a single arc whose span + opening follow the `placement`
+ * (90° in a corner, 180° on an edge / static), mirroring the OnionMenu's radial
+ * arc — the actions splay into the free space around the trigger.
+ */
+export type MenuDialLayout = 'linear' | 'arc';
+
+/** How each action's name is shown. `tooltip` floats it on hover/focus; `inline`
+ * is an always-visible pill beside the action; `none` keeps it sr-only. */
+export type MenuDialLabels = 'tooltip' | 'inline' | 'none';
+
+/** How the dial opens. */
+export type MenuDialOpenOn = 'click' | 'hover';
+
+/**
+ * Props for the root `` — a speed dial. The trigger IS a `` that
+ * fans out a linear set of `` mini-FABs. Compositional (the
+ * actions are children, never a `model` array). The root owns the open state,
+ * the keyboard / focus / dismiss behaviour and the perceptual emission.
+ */
+export interface MenuDialProps {
+ /** Fan geometry — a straight line or a placement-driven arc. @default 'linear' */
+ layout?: ResponsiveProp;
+ /** Which way the fan opens (`linear`); orients the arc on `static` placement. @default 'up' */
+ direction?: ResponsiveProp;
+ /**
+ * Arc radius (any CSS length) — distance from the trigger centre to the
+ * actions in `layout="arc"`. Defaults to a size-derived value that clears
+ * the trigger + action.
+ */
+ radius?: string;
+ /** Open on click (toggle) or on hover. @default 'click' */
+ openOn?: MenuDialOpenOn;
+ /** Which of the 9 zones the dial sits in (fixed), or `static`. Drives the arc
+ * span in `layout="arc"` (90° corner · 180° edge · 360° centre). @default 'bottom-end' */
+ placement?: ResponsiveProp;
+ /** FAB size scale — drives the trigger; actions are one step smaller. @default 'md' */
+ size?: ResponsiveProp;
+ /** How action names are shown. @default 'tooltip' */
+ labels?: MenuDialLabels;
+ /** Dim the page behind the open fan with a scrim. @default false */
+ backdrop?: boolean;
+ /** Stagger the actions in/out (each offset from the trigger). @default true */
+ stagger?: boolean;
+ /** Whether the fan is open. Bindable. @default false */
+ open?: boolean;
+ /** Trigger fill variant. @default 'solid' */
+ variant?: DialVariant;
+ /** Trigger colour role. */
+ color?: DialColor;
+ /** Trigger evaluative intent. */
+ intent?: DialIntent;
+ /** Trigger resting glyph (morphs to `openIcon` when open). Defaults to a `+`. */
+ icon?: Snippet;
+ /** Trigger open glyph. Defaults to rotating the resting `+` into a `×`. */
+ openIcon?: Snippet;
+ /** Accessible name for the trigger (the dial's purpose). */
+ 'aria-label'?: string;
+ /** Called when the open state changes. */
+ onOpenChange?: (open: boolean) => void;
+ /** The ``s. */
+ children?: Snippet;
+}
+
+/**
+ * Props for `` — one mini-FAB in the fan. Composes ``
+ * for the chrome; the dial adds the `menuitem` role, the elevation and the label.
+ */
+export interface MenuDialActionProps {
+ /** Visible label (tooltip / inline pill) + the accessible name. */
+ label?: string;
+ /** The glyph. */
+ icon?: Snippet;
+ /** Fired when the action is chosen (also closes the dial). */
+ onSelect?: () => void;
+ /** Disable the action. @default false */
+ disabled?: boolean;
+ /** Per-action fill variant (else the dial's). */
+ variant?: DialVariant;
+ /** Per-action colour role. */
+ color?: DialColor;
+ /** Per-action evaluative intent. */
+ intent?: DialIntent;
+ /** A nested glyph slot, when `icon` isn't used. */
+ children?: Snippet;
+}
diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts
index 4e76178f5..eb439e34a 100644
--- a/src/uix/langs/components/index.ts
+++ b/src/uix/langs/components/index.ts
@@ -41,6 +41,7 @@ import { linkPreviewLangs } from './link-preview';
import { listboxLangs } from './listbox';
import { menubarLangs } from './menubar';
import { meterLangs } from './meter';
+import { menuDialLangs } from './menu-dial';
import { monthGridLangs } from './month-grid';
import { navigationMenuLangs } from './navigation-menu';
import { numberFieldLangs } from './number-field';
@@ -132,6 +133,7 @@ export const componentLangs = {
'link-preview': linkPreviewLangs,
listbox: listboxLangs,
menubar: menubarLangs,
+ 'menu-dial': menuDialLangs,
meter: meterLangs,
'month-grid': monthGridLangs,
'navigation-menu': navigationMenuLangs,
diff --git a/src/uix/langs/components/menu-dial.ts b/src/uix/langs/components/menu-dial.ts
new file mode 100644
index 000000000..f51add3e1
--- /dev/null
+++ b/src/uix/langs/components/menu-dial.ts
@@ -0,0 +1,8 @@
+import type { LangNode } from '$libs/langs';
+
+export const menuDialLangs = {
+ label: {
+ es: 'Acciones rápidas',
+ en: 'Quick actions'
+ }
+} satisfies LangNode;
diff --git a/src/uix/morfo/components/menu-dial.ts b/src/uix/morfo/components/menu-dial.ts
new file mode 100644
index 000000000..65fa16767
--- /dev/null
+++ b/src/uix/morfo/components/menu-dial.ts
@@ -0,0 +1,151 @@
+import type { Morfo } from '../types';
+import { v } from '../types';
+
+/**
+ * MenuDial — a "speed dial": a FAB that fans out a LINEAR set of action buttons
+ * (each a mini-FAB with a label). The exact feature `fabMorfo` defers to here.
+ * Flat (no nesting) — for a few quick actions; the radial / nested case is
+ * OnionMenu's. By APG it's a Menu Button: the trigger (`aria-haspopup="menu"`)
+ * opens a `role="menu"` list of `menuitem` actions.
+ *
+ * scope: ['soma','sema','eidos'] — soma owns open + the linear keyboard nav,
+ * focus and dismiss; eidos composes `` (trigger) + `IconButton` (actions)
+ * and lays the fan out by `direction` (no SVG — plain flex); sema picks up
+ * open/close from `emerge` and commit-select from `commit` (family defaults).
+ */
+export const menuDialMorfo = {
+ name: 'MenuDial',
+ kebab: 'menu-dial',
+ scope: ['soma', 'sema', 'eidos'],
+ expression: 'family-default',
+ apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
+ texts: {
+ label: '#?components.menu-dial.label|Quick actions'
+ },
+
+ focus: {
+ initial: 'first-focusable',
+ // Non-modal: arrows move along the fan, but Tab / outside-click exit and
+ // close. The trigger gets focus back on close.
+ trap: false,
+ return: 'trigger',
+ restore: true
+ },
+
+ events: [
+ {
+ name: 'open',
+ semantic: { family: 'emerge', verb: 'open', target: v.partRef('list'), sequence: 'pre' },
+ commits: { part: v.partRef('list'), attr: 'data-state', value: 'open' }
+ },
+ {
+ name: 'close',
+ semantic: { family: 'emerge', verb: 'close', target: v.partRef('list'), sequence: 'pre' },
+ commits: { part: v.partRef('list'), attr: 'data-state', value: 'closed' }
+ },
+ {
+ name: 'commit-select',
+ semantic: {
+ family: 'commit',
+ verb: 'select',
+ target: v.partRef('action'),
+ intent: 'affirm',
+ sequence: 'post'
+ }
+ }
+ ],
+
+ parts: [
+ {
+ name: 'Provider',
+ kebab: 'provider',
+ archetype: 'provider',
+ kind: 'virtual',
+ defaultElement: 'none',
+ optional: false,
+ data: [],
+ aria: []
+ },
+ {
+ // The trigger IS a ``; the soma/eidos stamp this contract onto it.
+ 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('list'),
+ condition: { when: 'part-present', part: 'list' },
+ severity: 'recommended'
+ }
+ ],
+ keyboard: [
+ { key: 'Enter', action: 'open' },
+ { key: ' ', action: 'open' },
+ { key: 'ArrowUp', action: 'open' },
+ { key: 'ArrowDown', action: 'open' },
+ { key: 'ArrowLeft', action: 'open' },
+ { key: 'ArrowRight', action: 'open' }
+ ]
+ },
+ {
+ // The fan container (role="menu"). `data-direction` drives the layout.
+ name: 'List',
+ kebab: 'list',
+ 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: 'ArrowDown', action: 'next-item' },
+ { key: 'ArrowRight', action: 'next-item' },
+ { key: 'ArrowUp', action: 'prev-item' },
+ { key: 'ArrowLeft', action: 'prev-item' },
+ { key: 'Home', action: 'first-item' },
+ { key: 'End', action: 'last-item' },
+ { key: 'Enter', action: 'activate' },
+ { key: ' ', action: 'activate' }
+ ]
+ },
+ {
+ // One action = one mini-FAB (IconButton). `data-disabled` when disabled.
+ name: 'Action',
+ kebab: 'action',
+ archetype: 'item',
+ kind: 'public',
+ defaultElement: 'button',
+ role: 'menuitem',
+ optional: false,
+ states: [],
+ data: [{ attr: 'data-disabled', severity: 'optional' }],
+ aria: [
+ {
+ attr: 'aria-disabled',
+ value: v.literal('true'),
+ severity: 'optional',
+ condition: { when: 'prop-truthy', prop: 'disabled' }
+ }
+ ]
+ }
+ ]
+} as const satisfies Morfo;
diff --git a/src/uix/soma/components/menu-dial/index.ts b/src/uix/soma/components/menu-dial/index.ts
new file mode 100644
index 000000000..8c30bf187
--- /dev/null
+++ b/src/uix/soma/components/menu-dial/index.ts
@@ -0,0 +1,15 @@
+// MenuDial soma layer — the headless behaviour bridge for the speed dial.
+//
+// import { createMenuDialRuntime, dialNavNext } from '$soma/components/menu-dial';
+//
+// The eidos root owns the open state + the fan render; this layer owns the
+// perceptual emission + keyboard-action dispatch (`createMenuDialRuntime`) and
+// the pure linear focus maths (`dialNav*`).
+export { createMenuDialRuntime, menuDialMorfo } from './menu-dial.svelte';
+export {
+ dialNavNext,
+ dialNavPrev,
+ dialNavFirst,
+ dialNavLast,
+ type DialNav
+} from './menu-dial-nav';
diff --git a/src/uix/soma/components/menu-dial/menu-dial-nav.test.ts b/src/uix/soma/components/menu-dial/menu-dial-nav.test.ts
new file mode 100644
index 000000000..e25c020a4
--- /dev/null
+++ b/src/uix/soma/components/menu-dial/menu-dial-nav.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from 'vitest';
+import { dialNavNext, dialNavPrev, dialNavFirst, dialNavLast } from './menu-dial-nav';
+
+describe('menu-dial nav — wraps along the fan', () => {
+ const nav = { count: 4 };
+ it('next advances and wraps past the last back to the first', () => {
+ expect(dialNavNext(0, nav)).toBe(1);
+ expect(dialNavNext(3, nav)).toBe(0);
+ });
+ it('prev retreats and wraps past the first back to the last', () => {
+ expect(dialNavPrev(1, nav)).toBe(0);
+ expect(dialNavPrev(0, nav)).toBe(3);
+ });
+ it('first / last hit the ends', () => {
+ expect(dialNavFirst(nav)).toBe(0);
+ expect(dialNavLast(nav)).toBe(3);
+ });
+});
+
+describe('menu-dial nav — skips disabled actions', () => {
+ const nav = { count: 4, disabled: [1, 2] };
+ it('next / prev jump over the disabled run', () => {
+ expect(dialNavNext(0, nav)).toBe(3);
+ expect(dialNavPrev(3, nav)).toBe(0);
+ });
+ it('first / last skip disabled ends', () => {
+ expect(dialNavFirst({ count: 3, disabled: [0] })).toBe(1);
+ expect(dialNavLast({ count: 3, disabled: [2] })).toBe(1);
+ });
+});
+
+describe('menu-dial nav — degenerate', () => {
+ it('-1 when empty or all disabled', () => {
+ expect(dialNavFirst({ count: 0 })).toBe(-1);
+ expect(dialNavNext(0, { count: 2, disabled: [0, 1] })).toBe(-1);
+ });
+ it('a single enabled action is its own neighbour', () => {
+ expect(dialNavNext(0, { count: 1 })).toBe(0);
+ expect(dialNavLast({ count: 1 })).toBe(0);
+ });
+});
diff --git a/src/uix/soma/components/menu-dial/menu-dial-nav.ts b/src/uix/soma/components/menu-dial/menu-dial-nav.ts
new file mode 100644
index 000000000..0c943d2fe
--- /dev/null
+++ b/src/uix/soma/components/menu-dial/menu-dial-nav.ts
@@ -0,0 +1,47 @@
+/**
+ * MenuDial keyboard navigation — pure index transitions along the linear fan.
+ * Arrows wrap (a small dial of quick actions reads better looping than dead-
+ * ending), Home/End jump to the ends, and disabled actions are skipped. The
+ * eidos root owns the open state + render; these helpers just answer "where
+ * does focus go?" so the movement rule is testable in isolation.
+ */
+
+export interface DialNav {
+ /** Number of actions in the fan. */
+ readonly count: number;
+ /** Indices that are disabled (skipped by keyboard nav). */
+ readonly disabled?: readonly number[];
+}
+
+/** Walk `dir` steps from `from`, wrapping, skipping disabled. -1 if none focusable. */
+function step(from: number, dir: 1 | -1, nav: DialNav): number {
+ const { count } = nav;
+ if (count <= 0) return -1;
+ const disabled = new Set(nav.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;
+}
+
+/** Next focusable index after `from` (wraps; skips disabled). -1 if none. */
+export function dialNavNext(from: number, nav: DialNav): number {
+ return step(from, 1, nav);
+}
+
+/** Previous focusable index before `from` (wraps; skips disabled). -1 if none. */
+export function dialNavPrev(from: number, nav: DialNav): number {
+ return step(from, -1, nav);
+}
+
+/** First focusable index (skips leading disabled). -1 if none. */
+export function dialNavFirst(nav: DialNav): number {
+ return step(-1, 1, nav);
+}
+
+/** Last focusable index (skips trailing disabled). -1 if none. */
+export function dialNavLast(nav: DialNav): number {
+ return step(0, -1, nav);
+}
diff --git a/src/uix/soma/components/menu-dial/menu-dial.svelte.ts b/src/uix/soma/components/menu-dial/menu-dial.svelte.ts
new file mode 100644
index 000000000..68e501be1
--- /dev/null
+++ b/src/uix/soma/components/menu-dial/menu-dial.svelte.ts
@@ -0,0 +1,19 @@
+import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
+import { menuDialMorfo } from '$uix/morfo/components/menu-dial';
+
+/**
+ * The MenuDial soma runtime — the headless behaviour bridge. Like OnionMenu, the
+ * eidos root owns the open state + render, so soma here is:
+ *
+ * - `keydown('trigger' | 'list', event)` → the morfo keyboard contract
+ * (open from the trigger; Arrow / Home / End / Enter / Escape on the list);
+ * - `trigger('open' | 'close' | 'commit-select', { fallbackTarget })` → the
+ * perceptual signal (sound / haptic) + the a11y live region.
+ *
+ * Linear focus maths live in {@link './menu-dial-nav'} (testable apart).
+ */
+export function createMenuDialRuntime(sources: SomaRuntimeSources): SomaRuntime {
+ return createSomaRuntime(menuDialMorfo, sources);
+}
+
+export { menuDialMorfo };
diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte
index 5460ae163..8647ac9bc 100644
--- a/web/routes/uix/+layout@.svelte
+++ b/web/routes/uix/+layout@.svelte
@@ -153,7 +153,11 @@
{ slug: '/uix/components/collapsible', label: 'Collapsible' },
{ slug: '/uix/components/accordion', label: 'Accordion' },
{ slug: '/uix/components/tabs', label: 'Tabs' },
- { slug: '/uix/components/toolbar', label: 'Toolbar' }
+ { slug: '/uix/components/toolbar', label: 'Toolbar' },
+ { slug: '/uix/components/button', label: 'Button' },
+ { slug: '/uix/components/button-group', label: 'Button group' },
+ { slug: '/uix/components/split-button', label: 'Split button' },
+ { slug: '/uix/components/fab', label: 'Fab' }
]
},
{
@@ -267,6 +271,8 @@
{ slug: '/uix/components/dropdown-menu', label: 'Dropdown menu' },
{ slug: '/uix/components/context-menu', label: 'Context menu' },
{ slug: '/uix/components/menubar', label: 'Menubar' },
+ { slug: '/uix/components/onion-menu', label: 'Onion menu' },
+ { slug: '/uix/components/menu-dial', label: 'Menu dial' },
{ slug: '/uix/components/command', label: 'Command' },
{ slug: '/uix/components/link-preview', label: 'Link preview' },
{ slug: '/uix/components/toast', label: 'Toast' },
diff --git a/web/routes/uix/components/menu-dial/+page.svelte b/web/routes/uix/components/menu-dial/+page.svelte
new file mode 100644
index 000000000..fc7eefe04
--- /dev/null
+++ b/web/routes/uix/components/menu-dial/+page.svelte
@@ -0,0 +1,584 @@
+
+
+{#snippet iAdd()}{/snippet}
+{#snippet iConfirm()}{/snippet}
+{#snippet iMore()}{/snippet}
+{#snippet iCollapse()}{/snippet}
+{#snippet iRemove()}{/snippet}
+
+
+
+
Overlay · MenuDial
+
Menu dial
+
+ A speed dial: a FAB that fans out a linear set of action mini-FABs.
+ Compositional — the trigger is a <Fab>, each
+ <MenuDial.Action> a <Fab> (the Material "small FAB"). Flat
+ quick actions — the radial / nested case is OnionMenu's. The fan lays out by
+ direction in plain flex (no SVG).
+
+ Two components. <MenuDial> holds the trigger + the actions; each
+ <MenuDial.Action> is a mini-FAB.
+
+
MenuDial
+
+
+
Prop
Type
Default
Description
+
+
layout
'linear' | 'arc'
'linear'
Straight line, or a placement-driven arc (90° corner / 180° static).
+
direction
'up' | 'down' | 'left' | 'right'
'up'
Linear axis; orients the arc on static.
+
radius
string
— (size-derived)
Arc radius override (CSS length).
+
openOn
'click' | 'hover'
'click'
Open on click (toggle) or hover.
+
placement
MenuDialPlacement
'bottom-end'
One of 9 zones (corner / edge / centre) or static; sets the arc span (90/180/360°).
+
size
'sm' | 'md' | 'lg'
'md'
Trigger FAB size; actions are one step smaller.
+
labels
'tooltip' | 'inline' | 'none'
'tooltip'
How action names are shown.
+
backdrop
boolean
false
Scrim behind the open fan.
+
stagger
boolean
true
Stagger the actions in/out.
+
open
boolean
false
Bindable open state.
+
variant / color / intent
Button knobs
—
Trigger chrome.
+
icon / openIcon
Snippet
— (+→×)
Trigger glyph; omit for the default morph.
+
onOpenChange
(open) => void
—
Open-state change.
+
+
+
+
MenuDial.Action
+
+
+
Prop
Type
Default
Description
+
+
label
string
—
Name + tooltip / inline pill.
+
icon
Snippet
—
The glyph.
+
onSelect
() => void
—
Chosen (also closes the dial).
+
disabled
boolean
false
Disable + skip in nav.
+
variant / color / intent
Button knobs
—
Per-action chrome.
+
+
+
+
+ {/if}
+
+ {#if tab === 'morfo'}
+
+
+ morfo · declarative contract
+
+
+ The morfo declares the menu-button contract: the trigger, the role="menu" list,
+ the menuitem actions, and the open / close / commit-select events.
+
+
+ {#each menuDialMorfo.events as ev}
+ {@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: string; target?: { kind: string; ref?: string } }}
+
+
{ev.name}
+
{sem.family}
+
{sem.verb ?? '—'}
+
{sem.sequence ?? '—'}
+
{sem.intent ?? '—'}
+
{sem.target?.ref ?? sem.target?.kind ?? '—'}
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if tab === 'sema'}
+
+
+ sema · events
+
+
+ open / close (emerge · list) and commit-select
+ (commit · affirm · action). Expression family-default. The soma layer fires
+ these on real interaction; here ▶ play emits onto the live target.
+
+
+
+
name
family
verb
sequence
intent
play
+
+ {#each menuDialMorfo.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 ?? 'list'}
+
+
{ev.name}
+
{sem.family}
+
{sem.verb ?? '—'}
+
{sem.sequence ?? '—'}
+
{sem.intent ?? '—'}
+
+
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if tab === 'recipe'}
+
+
Eidos recipe
+
+ The trigger + actions are composed FABs; menu-dial.css owns only the placement,
+ the fan layout per data-direction, the open/close + stagger and the glyph.
+