feat(eidos): port Command (⌘K palette)
Closes #130. Eidos wrapper + 13 part wrappers + 2 new sub-parts +
Dialog composition + recipe + canonical-depth demo. Soma owns scoring
(`computeFuzzyScore`), keyboard nav (Arrow + vim Ctrl+N/P/J/K +
typeahead + loop + grid columns + RTL), item visibility, and the
imperative `CommandApi`. Eidos adds size + Item.Icon / Item.Shortcut
slots + Dialog composition with optional shortcut binding.
Reference parity:
| Feature | cmdk | Mantine Spotlight | bits-ui Command | this port |
|---|---|---|---|---|
| Root provider w/ bind:value, bind:search | ✓ | ✓ | ✓ | ✓ |
| Input, List, Viewport, Empty | ✓ | ✓ | ✓ | ✓ |
| Group + GroupHeading + GroupItems | ✓ | ✓ | ✓ | ✓ |
| Item value, keywords, disabled, onSelect | ✓ | ✓ | ✓ | ✓ |
| LinkItem (anchor) | ✗ | ✗ | ✓ | ✓ |
| Separator, Loading (progress 0-100) | ✓ | partial | ✓ | ✓ |
| Custom filter / shouldFilter | ✓ | ✓ | ✓ | ✓ |
| Loop, vim bindings, grid columns | ✓ | partial | ✓ | ✓ |
| disablePointerSelection | ✓ | ✗ | ✓ | ✓ |
| Modal Dialog composition | ✓ | ✓ (Root is modal) | ✓ | ✓ via eidos Dialog |
| Built-in ⌘K shortcut binding | ✗ (BYO) | ✓ | ✗ (BYO) | ✓ optional |
| Item.Icon / Item.Shortcut slots | BYO | leftSection/rightSection | BYO | ✓ NEW |
Naming: `Command.Root` aliased as the default export (cmdk/bits-ui
parity, eidos convention). Inner parts as named sub-properties:
`Command.Input`, `Command.List`, `Command.Item`, `Command.Item.Icon`,
`Command.Item.Shortcut`, `Command.Dialog`, …
Variant set: only `size: 'sm' | 'md' | 'lg'` (drops xs/xl — palette UX
needs comfortable hit targets, matches air baseline).
Shortcut binding: cross-platform `mod` token (Cmd on macOS, Ctrl
elsewhere). Parses `'mod+k'`, `'ctrl+shift+p'`, etc. Window keydown
listener registered on mount, cleaned up on unmount. Pass `null`/
undefined to disable.
Recipe: `command.css` paints palette container, input row, scrolling
List/Viewport, item rows (with highlighted [data-selected] state),
icon + shortcut slot layout, group heading, separator, empty state.
Loading progress is a 2px bar under Input with `--_command-loading-
progress` for the fill. Inside Command.Dialog the panel chrome comes
from dialog.css.
Demo: 6 tabs (Live/API/Morfo/Sema/Recipe/A11y) — Live wires every
soma + eidos prop + Dialog mode with shortcut input. Two stages:
inline palette + dialog opener with shortcut hint. Three code
snippets (soma, eidos inline, eidos dialog).
Gotcha note: command.svelte renames the children prop to `bodyContent`
on destructure to avoid shadowing the inner `{#snippet children}` — same
infinite-recursion trap noted in `feedback_button_consumer_pattern.md`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
< script lang = "ts" >
import {
Command,
type CommandSize
} from '$uix/eidos/components/command';
import { compileMorfo } from '$uix/morfo';
import { commandMorfo } from '@/uix/morfo/components/command';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state< Tab > ('live');
// ── Live state ────────────────────────────────────────────────────────
let size = $state< CommandSize > ('md');
let search = $state('');
let value = $state('');
let loop = $state(false);
let vimBindings = $state(true);
let columns = $state(1);
let disablePointerSelection = $state(false);
let shouldFilter = $state(true);
// Dialog mode
let dialogOpen = $state(false);
let dialogShortcut = $state('mod+k');
// Track invocations
let lastInvoked = $state< string | null > (null);
function invoke(name: string) {
return () => {
lastInvoked = name;
};
}
type TraceEntry = { event : string ; family : string ; intent? : string ; at : number } ;
let trace = $state< TraceEntry [ ] > ([]);
let stageRef = $state< HTMLElement | null > (null);
$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 compiled = compileMorfo(commandMorfo);
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' )} `;
}
const somaSnippet = $derived(
[
"< script lang = 'ts' > " ,
" import * as Command from '$soma/components/command';",
' let value = $state("");',
' let search = $state("");',
'< /' + 'script>',
'',
'< Command.Provider ' ,
' bind:value',
' bind:search',
loop & & ' loop',
!vimBindings && ' vimBindings={ false } ',
columns !== 1 && ` columns={ $ { columns }} `,
disablePointerSelection & & ' disablePointerSelection',
!shouldFilter && ' shouldFilter={ false } ',
'>',
' < Command.Input placeholder = "Search…" / > ',
' < Command.List > ',
' < Command.Empty > No results.< / Command.Empty > ',
' < Command.Group > ',
' < Command.GroupHeading > Files< / Command.GroupHeading > ',
' < Command.GroupItems > ',
' < Command.Item value = "open" onSelect = { … } > Open file </ Command . Item > ',
' < / Command.GroupItems > ',
' < / Command.Group > ',
' < / Command.List > ',
'< / Command.Provider > '
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"< script lang = 'ts' > " ,
" import { Command } from '$uix/eidos/components/command';",
' let value = $state("");',
' let search = $state("");',
'< /' + 'script>',
'',
'< Command ' ,
' bind:value',
' bind:search',
size !== 'md' && ` size="${ size } "`,
loop & & ' loop',
!vimBindings && ' vimBindings={ false } ',
columns !== 1 && ` columns={ $ { columns }} `,
disablePointerSelection & & ' disablePointerSelection',
!shouldFilter && ' shouldFilter={ false } ',
'>',
' < Command.Input placeholder = "Search…" / > ',
' < Command.List > ',
' < Command.Empty > No results.< / Command.Empty > ',
' < Command.Group > ',
' < Command.GroupHeading > Files< / Command.GroupHeading > ',
' < Command.GroupItems > ',
' < Command.Item value = "open" onSelect = { … } > ',
' < Command.Item.Icon > 📁< / Command.Item.Icon > ',
' Open file',
' < Command.Item.Shortcut > ⌘O< / Command.Item.Shortcut > ',
' < / Command.Item > ',
' < / Command.GroupItems > ',
' < / Command.Group > ',
' < / Command.List > ',
'< / Command > '
]
.filter(Boolean)
.join('\n')
);
const dialogSnippet = $derived(
[
"< script lang = 'ts' > " ,
" import { Command } from '$uix/eidos/components/command';",
' let open = $state(false);',
'< /' + 'script>',
'',
`< Command.Dialog bind:open shortcut = "$ { dialogShortcut } " > `,
' < Command.Input placeholder = "Type a command…" / > ',
' < Command.List > …< / Command.List > ',
'< / Command.Dialog > '
].join('\n')
);
< / script >
< div data-uix-canvas-inner >
< header >
< div data-uix-eyebrow > Overlays · Command< / div >
< h1 data-uix-page-title > Command< / h1 >
< p data-uix-page-lede >
⌘K command palette. Soma owns fuzzy scoring
(< code > computeFuzzyScore< / code > ), keyboard nav (arrow + vim
bindings + typeahead + loop + grid columns + RTL), item
visibility and the imperative < code > CommandApi< / code > . Eidos
paints the palette chrome, exposes < code > size< / code >
(sm/md/lg), adds < code > Item.Icon< / code > / < code > Item.Shortcut< / code >
sub-parts, and provides a < code > < Command.Dialog> < / code >
composition wired to the eidos Dialog with an optional
< code > mod+k< / code > shortcut binding.
< / 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 > sizes< / span > 3
< / span >
< span data-uix-meta-pill >
< span data-uix-meta-key > apg< / span >
< a href = "https://www.w3.org/WAI/ARIA/apg/patterns/combobox/" target = "_blank" rel = "noreferrer" > combobox ↗< / a >
< / span >
{ #if lastInvoked }
< span data-uix-meta-pill >
< span data-uix-meta-key > last</ span > { lastInvoked }
< / span >
{ /if }
< / div >
< / header >
< div data-uix-stage >
< div data-uix-stage-area bind:this = { stageRef } style="min-block-size: 20rem ; padding: var ( --uix-space-3 ); display: flex ; justify-content: center ;" >
< div style = "inline-size: min(28rem, 100%);" >
< Command
bind:value
bind:search
{ size }
{ loop }
{ vimBindings }
{ columns }
{ disablePointerSelection }
{ shouldFilter }
>
< Command.Input placeholder = "Search commands…" / >
< Command.List >
< Command.Empty > No commands match.< / Command.Empty >
< Command.Group >
< Command.GroupHeading > Files< / Command.GroupHeading >
< Command.GroupItems >
< Command.Item value = "new-file" onSelect = { invoke ( 'New file' )} >
< Command.Item.Icon > 📄< / Command.Item.Icon >
New file
< Command.Item.Shortcut > ⌘N< / Command.Item.Shortcut >
< / Command.Item >
< Command.Item value = "open-file" onSelect = { invoke ( 'Open file' )} >
< Command.Item.Icon > 📁< / Command.Item.Icon >
Open file…
< Command.Item.Shortcut > ⌘O< / Command.Item.Shortcut >
< / Command.Item >
< Command.Item value = "save-file" onSelect = { invoke ( 'Save file' )} >
< Command.Item.Icon > 💾< / Command.Item.Icon >
Save file
< Command.Item.Shortcut > ⌘S< / Command.Item.Shortcut >
< / Command.Item >
< / Command.GroupItems >
< / Command.Group >
< Command.Separator / >
< Command.Group >
< Command.GroupHeading > Edit< / Command.GroupHeading >
< Command.GroupItems >
< Command.Item value = "undo" keywords = {[ 'ctrl-z' , 'rollback' ]} onSelect= { invoke ( 'Undo' )} >
< Command.Item.Icon > ↶< / Command.Item.Icon >
Undo
< Command.Item.Shortcut > ⌘Z< / Command.Item.Shortcut >
< / Command.Item >
< Command.Item value = "redo" keywords = {[ 'ctrl-y' ]} onSelect= { invoke ( 'Redo' )} >
< Command.Item.Icon > ↷< / Command.Item.Icon >
Redo
< Command.Item.Shortcut > ⇧⌘Z< / Command.Item.Shortcut >
< / Command.Item >
< Command.Item value = "find" keywords = {[ 'search' ]} onSelect= { invoke ( 'Find' )} >
< Command.Item.Icon > 🔍< / Command.Item.Icon >
Find in file
< Command.Item.Shortcut > ⌘F< / Command.Item.Shortcut >
< / Command.Item >
< / Command.GroupItems >
< / Command.Group >
< Command.Separator / >
< Command.Group >
< Command.GroupHeading > Help< / Command.GroupHeading >
< Command.GroupItems >
< Command.LinkItem value = "docs" href = "https://example.com/docs" onSelect = { invoke ( 'Docs' )} >
< Command.Item.Icon > 📚< / Command.Item.Icon >
Open documentation
< Command.Item.Shortcut > ↗< / Command.Item.Shortcut >
< / Command.LinkItem >
< Command.Item value = "about" disabled onSelect = { invoke ( 'About' )} >
< Command.Item.Icon > ℹ ︎ < / Command.Item.Icon >
About (disabled)
< / Command.Item >
< / Command.GroupItems >
< / Command.Group >
< / Command.List >
< / Command >
< / div >
< / div >
< div data-uix-stage-trace >
< span data-uix-stage-trace-key > trace< / span >
{ #if trace . length === 0 }
< span > type or arrow-nav to highlight, Enter to invoke< / span >
{ : else }
fix(demos): eliminate each_key_duplicate crash in 22 component demos
The event-trace inspector in each demo keyed its `{#each}` by a
`Date.now()` timestamp (`entry.at` / `h.at`). A single interaction can
stamp `data-event` on several elements within the same millisecond, so
two trace entries share the key and Svelte throws `each_key_duplicate`
(reported on dropdown-menu).
The trace is an ephemeral, 3-item, text-only log with no transitions or
stateful children, so positional reconciliation is correct: drop the
timestamp key (the each becomes unkeyed). The `fmtTime(...at)` display is
left intact.
Swept all 22 demos carrying the pattern (21 keyed by `entry.at` + announce
by `h.at`): dropdown-menu, context-menu, menubar, navigation-menu, listbox,
grid-list, table, tree-view, tree-grid, feed, command, carousel, drag-drop,
clipboard, announce, alert-dialog, button, color-field, link-preview,
range-calendar, time-field, time-range-field.
Verified: `as \w+ (\w+.at)` -> 0 occurrences site-wide; `npm run check`
0 errors; browser repro on dropdown-menu + table (burst of events, no
each_key_duplicate, console clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
{ #each trace . slice ( 0 , 3 ) as entry }
feat(eidos): port Command (⌘K palette)
Closes #130. Eidos wrapper + 13 part wrappers + 2 new sub-parts +
Dialog composition + recipe + canonical-depth demo. Soma owns scoring
(`computeFuzzyScore`), keyboard nav (Arrow + vim Ctrl+N/P/J/K +
typeahead + loop + grid columns + RTL), item visibility, and the
imperative `CommandApi`. Eidos adds size + Item.Icon / Item.Shortcut
slots + Dialog composition with optional shortcut binding.
Reference parity:
| Feature | cmdk | Mantine Spotlight | bits-ui Command | this port |
|---|---|---|---|---|
| Root provider w/ bind:value, bind:search | ✓ | ✓ | ✓ | ✓ |
| Input, List, Viewport, Empty | ✓ | ✓ | ✓ | ✓ |
| Group + GroupHeading + GroupItems | ✓ | ✓ | ✓ | ✓ |
| Item value, keywords, disabled, onSelect | ✓ | ✓ | ✓ | ✓ |
| LinkItem (anchor) | ✗ | ✗ | ✓ | ✓ |
| Separator, Loading (progress 0-100) | ✓ | partial | ✓ | ✓ |
| Custom filter / shouldFilter | ✓ | ✓ | ✓ | ✓ |
| Loop, vim bindings, grid columns | ✓ | partial | ✓ | ✓ |
| disablePointerSelection | ✓ | ✗ | ✓ | ✓ |
| Modal Dialog composition | ✓ | ✓ (Root is modal) | ✓ | ✓ via eidos Dialog |
| Built-in ⌘K shortcut binding | ✗ (BYO) | ✓ | ✗ (BYO) | ✓ optional |
| Item.Icon / Item.Shortcut slots | BYO | leftSection/rightSection | BYO | ✓ NEW |
Naming: `Command.Root` aliased as the default export (cmdk/bits-ui
parity, eidos convention). Inner parts as named sub-properties:
`Command.Input`, `Command.List`, `Command.Item`, `Command.Item.Icon`,
`Command.Item.Shortcut`, `Command.Dialog`, …
Variant set: only `size: 'sm' | 'md' | 'lg'` (drops xs/xl — palette UX
needs comfortable hit targets, matches air baseline).
Shortcut binding: cross-platform `mod` token (Cmd on macOS, Ctrl
elsewhere). Parses `'mod+k'`, `'ctrl+shift+p'`, etc. Window keydown
listener registered on mount, cleaned up on unmount. Pass `null`/
undefined to disable.
Recipe: `command.css` paints palette container, input row, scrolling
List/Viewport, item rows (with highlighted [data-selected] state),
icon + shortcut slot layout, group heading, separator, empty state.
Loading progress is a 2px bar under Input with `--_command-loading-
progress` for the fill. Inside Command.Dialog the panel chrome comes
from dialog.css.
Demo: 6 tabs (Live/API/Morfo/Sema/Recipe/A11y) — Live wires every
soma + eidos prop + Dialog mode with shortcut input. Two stages:
inline palette + dialog opener with shortcut hint. Three code
snippets (soma, eidos inline, eidos dialog).
Gotcha note: command.svelte renames the children prop to `bodyContent`
on destructure to avoid shadowing the inner `{#snippet children}` — same
infinite-recursion trap noted in `feedback_button_consumer_pattern.md`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
< span >< span data-uix-stage-trace-event > { entry . event } </ span > · { entry . family }{ entry . intent ? ' · ' + entry . intent : '' } </ span >
< span style = "color: var(--uix-text-faint)" > { fmtTime ( entry . at )} </ span >
{ /each }
{ /if }
< span style = "margin-inline-start: auto;" >
< span data-uix-stage-trace-key > value</ span > { value || '—' }
< / span >
< / div >
< / div >
<!-- Modal Dialog opener — separate stage row so consumers see both modes -->
< div data-uix-stage style = "margin-top: var(--uix-space-3);" >
< div data-uix-stage-area style = "min-block-size: 6rem; display: flex; align-items: center; justify-content: center; gap: var(--uix-space-3);" >
< button
onclick={() => ( dialogOpen = ! dialogOpen )}
style="padding: var(--uix-space-2) var(--uix-space-4); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-raised); cursor: pointer;"
>
{ dialogOpen ? 'Close' : 'Open' } Command.Dialog (or press { dialogShortcut } )
< / button >
< / div >
< / div >
< Command.Dialog bind:open = { dialogOpen } shortcut= { dialogShortcut } { size } >
< Command.Input placeholder = "Type a command or search…" / >
< Command.List >
< Command.Empty > No matches.< / Command.Empty >
< Command.Group >
< Command.GroupHeading > Quick actions< / Command.GroupHeading >
< Command.GroupItems >
< Command.Item value = "dlg-new" onSelect = { invoke ( 'Dialog · New' )} >
< Command.Item.Icon > + < / Command.Item.Icon >
New project
< Command.Item.Shortcut > ⌘N< / Command.Item.Shortcut >
< / Command.Item >
< Command.Item value = "dlg-search" onSelect = { invoke ( 'Dialog · Search' )} >
< Command.Item.Icon > 🔍< / Command.Item.Icon >
Global search
< Command.Item.Shortcut > ⌘P< / Command.Item.Shortcut >
< / Command.Item >
< Command.Item value = "dlg-settings" onSelect = { invoke ( 'Dialog · Settings' )} >
< Command.Item.Icon > ⚙︎< / Command.Item.Icon >
Open settings
< Command.Item.Shortcut > ⌘,< / Command.Item.Shortcut >
< / Command.Item >
< / Command.GroupItems >
< / Command.Group >
< / Command.List >
< / Command.Dialog >
< 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 > 15< / span >
< / button >
< button data-uix-tab data-active = { tab === 'morfo' } onclick= {() => ( tab = 'morfo' )} >
< span data-uix-layer-badge = "morfo" > morfo< / span >
< span data-uix-tab-count > { partsList . length } p · { events . length } e</ span >
< / button >
< button data-uix-tab data-active = { tab === 'sema' } onclick= {() => ( tab = 'sema' )} >
< span data-uix-layer-badge = "sema" > sema< / span >
< span data-uix-tab-count > { events . length } </ span >
< / button >
< button data-uix-tab data-active = { tab === 'recipe' } onclick= {() => ( tab = 'recipe' )} > Recipe</ button >
< button data-uix-tab data-active = { tab === 'a11y' } onclick= {() => ( tab = 'a11y' )} > A11y</ button >
< / div >
{ #if tab === 'live' }
< section data-uix-section >
< h2 data-uix-section-title > Controls< / h2 >
< p data-uix-section-desc >
Soma owns scoring + keyboard nav. Eidos adds < code > size< / code > on the root and the < code > Item.Icon< / code > / < code > Item.Shortcut< / code > layout sub-parts.
< / p >
< div data-uix-subsection-head >
< span data-uix-layer-badge = "soma" > soma< / span > Provider props
< / div >
< div data-uix-controls >
< label data-uix-control >
< span data-uix-control-label > search < span data-uix-control-hint > bindable< / span > < / span >
< input type = "text" bind:value = { search } placeholder="(empty)" />
< / label >
< label data-uix-control >
< span data-uix-control-label > value < span data-uix-control-hint > highlighted item< / span > < / span >
< input type = "text" value = { value || '—' } readonly />
< / label >
< label data-uix-control >
< span data-uix-control-label > loop < span data-uix-control-hint > arrow nav wraps< / span > < / span >
< span data-uix-switch >
< input type = "checkbox" bind:checked = { loop } / >
< span data-uix-switch-label > { loop ? 'on' : 'off' } </ span >
< / span >
< / label >
< label data-uix-control >
< span data-uix-control-label > vimBindings < span data-uix-control-hint > Ctrl+N/P/J/K< / span > < / span >
< span data-uix-switch >
< input type = "checkbox" bind:checked = { vimBindings } / >
< span data-uix-switch-label > { vimBindings ? 'on' : 'off' } </ span >
< / span >
< / label >
< label data-uix-control >
< span data-uix-control-label > columns < span data-uix-control-hint > { columns } </ span ></ span >
< input type = "range" min = "1" max = "4" step = "1" bind:value = { columns } / >
< / label >
< label data-uix-control >
< span data-uix-control-label > disablePointerSelection< / span >
< span data-uix-switch >
< input type = "checkbox" bind:checked = { disablePointerSelection } / >
< span data-uix-switch-label > { disablePointerSelection ? 'on' : 'off' } </ span >
< / span >
< / label >
< label data-uix-control >
< span data-uix-control-label > shouldFilter< / span >
< span data-uix-switch >
< input type = "checkbox" bind:checked = { shouldFilter } / >
< span data-uix-switch-label > { shouldFilter ? 'on' : 'off' } </ span >
< / span >
< / label >
< / div >
< div data-uix-subsection-head >
< span data-uix-layer-badge = "eidos" > eidos< / span > visual size
< / div >
< div data-uix-controls >
< label data-uix-control >
< span data-uix-control-label > size< / span >
< span data-uix-chips role = "radiogroup" >
{ #each [ 'sm' , 'md' , 'lg' ] as s ( s )}
< button data-uix-chip data-active = { size === s } onclick= {() => ( size = s as CommandSize )} > { s } </ button >
{ /each }
< / span >
< / label >
< / div >
< div data-uix-subsection-head >
< span data-uix-layer-badge = "eidos" > eidos< / span > Dialog mode
< span style = "font-weight: normal; color: var(--uix-text-muted);" >
· built on top of < Dialog> with optional shortcut binding
< / span >
< / div >
< div data-uix-controls >
< label data-uix-control >
< span data-uix-control-label > shortcut < span data-uix-control-hint > e.g. mod+k, ctrl+shift+p< / span > < / span >
< input type = "text" bind:value = { dialogShortcut } / >
< / label >
< label data-uix-control >
< span data-uix-control-label > dialog open < span data-uix-control-hint > bindable< / span > < / span >
< span data-uix-switch >
< input type = "checkbox" bind:checked = { dialogOpen } / >
< span data-uix-switch-label > { dialogOpen ? 'open' : 'closed' } </ span >
< / span >
< / label >
< / div >
< div data-uix-code >
< div data-uix-code-head >
< span data-uix-layer-badge = "soma" > soma< / span >
< span > headless · inline palette< / span >
< span data-uix-code-lang > svelte< / span >
< / div >
< pre >< code > { somaSnippet } </ code ></ pre >
< / div >
< div data-uix-code style = "margin-top: var(--uix-space-3);" >
< div data-uix-code-head >
< span data-uix-layer-badge = "eidos" > eidos< / span >
< span > visual · size + Item sub-parts< / span >
< span data-uix-code-lang > svelte< / span >
< / div >
< pre >< code > { eidosSnippet } </ code ></ pre >
< / div >
< div data-uix-code style = "margin-top: var(--uix-space-3);" >
< div data-uix-code-head >
< span data-uix-layer-badge = "eidos" > eidos< / span >
< span > modal · Command.Dialog with mod+k binding< / span >
< span data-uix-code-lang > svelte< / span >
< / div >
< pre >< code > { dialogSnippet } </ 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 >
13 soma parts plus 3 eidos additions: < code > Command.Dialog< / code >
(wraps eidos Dialog), < code > Command.Item.Icon< / code > and
< code > Command.Item.Shortcut< / code > (Item layout sub-parts).
< / p >
< div data-uix-subsection-head > Provider (Command)< / 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" > value < span data-uix-tag data-kind = "bindable" > bindable< / span > < / td > < td class = "type" > string< / td > < td class = "default" > ''< / td > < td > Currently highlighted item value.< / td > < / tr >
< tr > < td class = "name" > search < span data-uix-tag data-kind = "bindable" > bindable< / span > < / td > < td class = "type" > string< / td > < td class = "default" > ''< / td > < td > Current search string.< / td > < / tr >
< tr > < td class = "name" > shouldFilter< / td > < td class = "type" > boolean< / td > < td class = "default" > true< / td > < td > Whether to run the scoring filter over items.< / td > < / tr >
< tr > < td class = "name" > filter< / td > < td class = "type" > CommandFilterFn< / td > < td class = "default" > computeFuzzyScore< / td > < td > Custom scoring function. Return 0 to exclude.< / td > < / tr >
< tr > < td class = "name" > loop< / td > < td class = "type" > boolean< / td > < td class = "default" > false< / td > < td > Loop keyboard navigation at the edges.< / td > < / tr >
< tr > < td class = "name" > vimBindings< / td > < td class = "type" > boolean< / td > < td class = "default" > true< / td > < td > Ctrl+N/J = next, Ctrl+P/K = previous.< / td > < / tr >
< tr > < td class = "name" > columns< / td > < td class = "type" > number< / td > < td class = "default" > 1< / td > < td > Grid columns — arrows move ±1 (Right/Left) or ±columns (Down/Up).< / td > < / tr >
< tr > < td class = "name" > disablePointerSelection< / td > < td class = "type" > boolean< / td > < td class = "default" > false< / td > < td > Hovering items doesn't change the highlight.< / td > < / tr >
< tr > < td class = "name" > dir< / td > < td class = "type" > 'ltr' | 'rtl'< / td > < td class = "default empty" > from soma config< / td > < td > Grid arrow inversion in RTL.< / td > < / tr >
< tr > < td class = "name" > size < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td class = "type" > ResponsiveProp< 'sm' | 'md' | 'lg'> < / td > < td class = "default" > 'md'< / td > < td > Visual size — input height + item height + font.< / td > < / tr >
< tr > < td class = "name" > shortcut < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td class = "type" > string | string[]< / td > < td class = "default empty" > —< / td > < td > Optional keyboard shortcut (e.g. < code > 'mod+k'< / code > ) that toggles open. Requires bindable open.< / td > < / tr >
< / tbody >
< / table >
< / div >
< div data-uix-subsection-head > Item< / 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" > value< / td > < td class = "type" > string< / td > < td class = "default empty" > required< / td > < td > Unique value used by the scorer.< / td > < / tr >
< tr > < td class = "name" > keywords< / td > < td class = "type" > readonly string[]< / td > < td class = "default" > []< / td > < td > Text aliases the scorer also matches.< / td > < / tr >
< tr > < td class = "name" > disabled< / td > < td class = "type" > boolean< / td > < td class = "default" > false< / td > < td > Disable activation. Still focusable.< / td > < / tr >
< tr > < td class = "name" > forceMount< / td > < td class = "type" > boolean< / td > < td class = "default" > false< / td > < td > Render even when filtered out.< / td > < / tr >
< tr > < td class = "name" > onSelect< / td > < td class = "type" > () => void< / td > < td class = "default empty" > —< / td > < td > Fired on Enter or click.< / td > < / tr >
< / tbody >
< / table >
< / div >
< div data-uix-subsection-head > Item.Icon / Item.Shortcut < span data-uix-tag data-kind = "eidos" > eidos< / span > < / div >
< p data-uix-section-desc style = "margin-top: 0.25rem;" >
Sub-parts of < code > Item< / code > . Both accept < code > children< / code > ; the recipe handles layout — Icon is a leading flex slot, Shortcut is pushed to row's end via < code > margin-inline-start: auto< / code > . Pair with < code > < Kbd> < / code > for richer shortcut styling.
< / p >
< div data-uix-subsection-head > Command.Dialog < span data-uix-tag data-kind = "eidos" > eidos< / span > < / 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" > open < span data-uix-tag data-kind = "bindable" > bindable< / span > < / td > < td class = "type" > boolean< / td > < td class = "default" > false< / td > < td > Dialog open state.< / td > < / tr >
< tr > < td class = "name" > shortcut< / td > < td class = "type" > string | string[] | null< / td > < td class = "default" > 'mod+k'< / td > < td > Keyboard binding(s) to toggle. < code > null< / code > disables.< / td > < / tr >
< tr > < td class = "name" > size< / td > < td class = "type" > 'sm' | 'md' | 'lg'< / td > < td class = "default" > 'md'< / td > < td > Inner Command palette size.< / td > < / tr >
< tr > < td class = "name" > dialogSize< / td > < td class = "type" > DialogSize< / td > < td class = "default" > 'md'< / td > < td > Eidos Dialog panel size (sm | md | lg | xl | full).< / td > < / tr >
< tr > < td class = "name" > modal · label · value · search · shouldFilter · filter · loop · …< / td > < td class = "type" > pass-through< / td > < td class = "default empty" > —< / td > < td > Forwarded to Dialog + Command.< / 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 > Source: < code > src/uix/morfo/components/command.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" > "{ commandMorfo . name } "</ td ></ tr >
< tr >< td class = "name" > kebab</ td >< td class = "type" > "{ commandMorfo . kebab } "</ td ></ tr >
< tr >< td class = "name" > scope</ td >< td class = "type" > [{ commandMorfo . scope . map (( s ) => `"$ { s } " `).join(', ') } ]</ td ></ tr >
< tr >< td class = "name" > parts.length</ td >< td class = "default" > { commandMorfo . 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 > < th > Optional< / th > < / tr > < / thead >
< tbody >
{ #each partsList as part ( part . kebab )}
< tr >
< td class = "name" > { part . kebab } </ td >
< td >< code data-uix-part-marker > [{ part . marker } ]</ code ></ td >
< td class = "type" > < { part . defaultElement } > </ 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 >
< div data-uix-subsection-head > Events declaration< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > name< / th > < th > family< / th > < th > verb< / th > < th > sequence< / th > < th > intent< / th > < th > target< / th > < / tr > < / thead >
< tbody >
{ #each events as action ( action . name )}
{ @const sem = action . semantic }
{ @const intentDecl = 'intent' in sem ? sem.intent : undefined }
{ @const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:$ {( intentDecl as { fromProp? : string }). fromProp ?? '?' } ` : '—' }
< tr >
< td class = "name" > { action . name } </ td >
< td class = "type" > { sem . family } </ td >
< td > { sem . verb ?? '—' } </ td >
< td > { sem . sequence ?? 'pre' } </ td >
< td class = "default" > { intentStr } </ td >
< td > { action . target } </ td >
< / tr >
{ /each }
< / tbody >
< / table >
< / div >
< / section >
{ /if }
{ #if tab === 'sema' }
< section data-uix-section id = "sema" >
< h2 data-uix-section-title >
< span data-uix-layer-badge = "sema" > sema< / span >
· events + perceptual signature
< / h2 >
< p data-uix-section-desc >
One event: < code > commit-invoke< / code > fires when the user
activates a highlighted Item (Enter or click). Verb < code > submit< / code > ,
intent < code > fulfill< / code > .
< / p >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Name< / th > < th > Family< / th > < th > Verb< / th > < th > Sequence< / th > < th > Intent< / th > < th > Play< / th > < / tr > < / thead >
< tbody >
{ #each events as action ( action . name )}
{ @const intentDecl = 'intent' in action . semantic ? action.semantic.intent : undefined }
{ @const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined }
< tr >
< td class = "name" > { action . name } </ td >
< td class = "type" > { action . semantic . family } </ td >
< td > { action . semantic . verb ?? '—' } </ td >
< td > { action . semantic . sequence ?? 'pre' } </ td >
< td class = "default" > { effectiveIntent ?? '—' } </ td >
< td >
< button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-command-item]') ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent : effectiveIntent } : {} )
});
}}
>▶ play< / button >
< / td >
< / tr >
{ /each }
< / tbody >
< / table >
< / div >
< / section >
{ /if }
{ #if tab === 'recipe' }
< section data-uix-section >
< h2 data-uix-section-title > Eidos recipe< / h2 >
< p data-uix-section-desc >
Selectors at < code > src/uix/eidos/components/command/command.css< / code > .
Inside < code > < Command.Dialog> < / code > the dialog panel chrome
comes from < code > dialog.css< / code > and the inner palette uses these
selectors.
< / p >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Selector< / th > < th > Source< / th > < th > What it paints< / th > < / tr > < / thead >
< tbody >
< tr > < td class = "name" > [data-command]< / td > < td > < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td > Palette container — flex column, border, surface bg.< / td > < / tr >
< tr > < td class = "name" > [data-command][data-size]< / td > < td > < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td > Size variant — input + item heights, font-size.< / td > < / tr >
< tr > < td class = "name" > [data-command-input]< / td > < td > < span data-uix-tag data-kind = "soma" > morfo< / span > < / td > < td > Search input row + divider.< / td > < / tr >
< tr > < td class = "name" > [data-command-list], [data-command-viewport]< / td > < td > < span data-uix-tag data-kind = "soma" > morfo< / span > < / td > < td > Scrolling list.< / td > < / tr >
< tr > < td class = "name" > [data-command-item], [data-command-link-item]< / td > < td > < span data-uix-tag data-kind = "soma" > morfo< / span > < / td > < td > Row — flex, padding, gap.< / td > < / tr >
< tr > < td class = "name" > [data-command-item][data-selected]< / td > < td > < span data-uix-tag data-kind = "soma" > morfo< / span > < / td > < td > Highlighted row tint.< / td > < / tr >
< tr > < td class = "name" > [data-command-item-icon], [data-command-item-shortcut]< / td > < td > < span data-uix-tag data-kind = "eidos" > eidos< / span > < / td > < td > Item sub-part layout slots — icon fixed, shortcut auto-end.< / td > < / tr >
< tr > < td class = "name" > [data-command-group-heading]< / td > < td > < span data-uix-tag data-kind = "soma" > morfo< / span > < / td > < td > Muted uppercase group label.< / td > < / tr >
< tr > < td class = "name" > [data-command-separator]< / td > < td > < span data-uix-tag data-kind = "soma" > morfo< / span > < / td > < td > 1px divider between groups.< / td > < / tr >
< / tbody >
< / table >
< / div >
< / section >
{ /if }
{ #if tab === 'a11y' }
< section data-uix-section >
< h2 data-uix-section-title > Accessibility< / h2 >
< p data-uix-section-desc >
Follows the APG < a href = "https://www.w3.org/WAI/ARIA/apg/patterns/combobox/" target = "_blank" rel = "noreferrer" > combobox pattern< / a > with a listbox popup. Input has < code > role="combobox"< / code > ; List is < code > role="listbox"< / code > ; each Item is < code > role="option"< / code > .
< / p >
< div data-uix-subsection-head > Keyboard< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Key< / th > < th > Action< / th > < / tr > < / thead >
< tbody >
< tr > < td > < span data-uix-kbd > ArrowDown< / span > / < span data-uix-kbd > ArrowUp< / span > < / td > < td > Move highlight ±1 (or ±columns if columns > 1).< / td > < / tr >
< tr > < td > < span data-uix-kbd > ArrowRight< / span > / < span data-uix-kbd > ArrowLeft< / span > < / td > < td > In grid mode, move ±1 column (RTL inverts).< / td > < / tr >
< tr > < td > < span data-uix-kbd > Ctrl+N< / span > / < span data-uix-kbd > Ctrl+J< / span > < / td > < td > Next (vim binding, if < code > vimBindings=true< / code > ).< / td > < / tr >
< tr > < td > < span data-uix-kbd > Ctrl+P< / span > / < span data-uix-kbd > Ctrl+K< / span > < / td > < td > Previous (vim binding).< / td > < / tr >
< tr > < td > < span data-uix-kbd > Home< / span > / < span data-uix-kbd > End< / span > < / td > < td > First / last visible item.< / td > < / tr >
< tr > < td > < span data-uix-kbd > Enter< / span > < / td > < td > Activate highlighted item — fires < code > onSelect< / code > + < code > commit-invoke< / code > .< / td > < / tr >
< tr > < td > < span data-uix-kbd > Escape< / span > < / td > < td > Clear search (or close dialog if inside < code > Command.Dialog< / code > ).< / td > < / tr >
< tr > < td > < span data-uix-kbd > mod+k< / span > < / td > < td > Toggle dialog open/close (if < code > shortcut< / code > is configured).< / td > < / tr >
< / tbody >
< / table >
< / div >
< div data-uix-subsection-head > ARIA contract< / div >
< div data-uix-table-wrap >
< table data-uix-table >
< thead > < tr > < th > Part< / th > < th > Attribute< / th > < th > Value< / th > < / tr > < / thead >
< tbody >
< tr > < td class = "name" > input< / td > < td > role< / td > < td class = "type" > "combobox"< / td > < / tr >
< tr > < td class = "name" > input< / td > < td > aria-expanded< / td > < td class = "type" > "true" (always while palette is mounted)< / td > < / tr >
< tr >< td class = "name" > input</ td >< td > aria-controls</ td >< td class = "type" > { `<list id>` } </ td ></ tr >
< tr >< td class = "name" > input</ td >< td > aria-activedescendant</ td >< td class = "type" > { `<highlighted item id>` } </ td ></ tr >
< tr > < td class = "name" > list< / td > < td > role< / td > < td class = "type" > "listbox"< / td > < / tr >
< tr > < td class = "name" > item< / td > < td > role< / td > < td class = "type" > "option"< / td > < / tr >
< tr > < td class = "name" > item< / td > < td > aria-selected< / td > < td class = "type" > "true" (when highlighted)< / td > < / tr >
< tr > < td class = "name" > item< / td > < td > aria-disabled< / td > < td class = "type" > "true" (when disabled)< / td > < / tr >
< / tbody >
< / table >
< / div >
< / section >
{ /if }
< / div >