You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/uix/components/button/+page.svelte

855 lines
40 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script lang="ts">
import {
Button,
type ButtonVariant,
type ButtonSize,
type ButtonRounded
} from '$uix/eidos/components/button';
import { SHAPE_FAMILIES, type ShapeFamily } from '$uix/eidos/lib/types';
import type { ButtonIntent } from '$soma/components/button';
import { compileMorfo } from '$uix/morfo';
import { buttonMorfo } from '@/uix/morfo/components/button';
import { getActiveUix } from '$active-uix';
import * as Icon from '$uix/eidos/components/icon';
const uix = getActiveUix();
type IconKey = 'none' | 'save' | 'send' | 'trash' | 'settings' | 'heart' | 'plus' | 'download' | 'pencil' | 'check' | 'x';
const iconKeys: IconKey[] = ['none', 'save', 'send', 'trash', 'settings', 'heart', 'plus', 'download', 'pencil', 'check', 'x'];
const iconMap = {
none: null,
save: Icon.Save,
send: Icon.Send,
trash: Icon.Trash,
settings: Icon.Settings,
heart: Icon.Heart,
plus: Icon.Plus,
download: Icon.Download,
pencil: Icon.Pencil,
check: Icon.Check,
x: Icon.X
} as const;
type Tab = 'live' | 'gallery' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
const variants: ButtonVariant[] = ['solid', 'soft', 'surface', 'outline', 'ghost', 'plain'];
const sizes: ButtonSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const roundeds: ButtonRounded[] = ['sm', 'md', 'lg', 'xl', 'full'];
const intents: ButtonIntent[] = ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'];
const colors: Array<'primary' | 'secondary' | 'neutral'> = ['primary', 'secondary', 'neutral'];
const types: Array<'button' | 'submit' | 'reset'> = ['button', 'submit', 'reset'];
// Gallery: all 8 color roles. Hierarchy (primary/secondary/neutral) rides the
// `color` prop; the evaluative palette (affirm/fulfill/risk/threat/loss) rides
// `intent`, which wins on data-color.
const gallery: Array<{ name: string; color?: 'primary' | 'secondary' | 'neutral'; intent: ButtonIntent }> = [
{ name: 'primary', color: 'primary', intent: 'neutral' },
{ name: 'secondary', color: 'secondary', intent: 'neutral' },
{ name: 'neutral', color: 'neutral', intent: 'neutral' },
{ name: 'affirm', intent: 'affirm' },
{ name: 'fulfill', intent: 'fulfill' },
{ name: 'risk', intent: 'risk' },
{ name: 'threat', intent: 'threat' },
{ name: 'loss', intent: 'loss' }
];
// ── Live state ────────────────────────────────────────────────────────
// Soma
let intent = $state<ButtonIntent>('neutral');
let color = $state<'primary' | 'secondary' | 'neutral' | undefined>(undefined);
let buttonType = $state<'button' | 'submit' | 'reset'>('button');
let disabled = $state(false);
let loading = $state(false);
let loadingText = $state('');
let ariaLabel = $state('');
// Eidos
let variant = $state<ButtonVariant>('solid');
let size = $state<ButtonSize>('md');
let rounded = $state<ButtonRounded | undefined>(undefined);
let shape = $state<ShapeFamily>('rounded');
let block = $state(false);
let iconOnly = $state(false);
let spinnerPlacement = $state<'start' | 'end'>('start');
let startIcon = $state<IconKey>('save');
let endIcon = $state<IconKey>('none');
// Demo content
let label = $state('Save changes');
let pressCount = $state(0);
const StartIconComp = $derived(iconMap[startIcon]);
const EndIconComp = $derived(iconMap[endIcon]);
function handlePress() {
pressCount++;
}
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(buttonMorfo);
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 Button from '$soma/components/button';",
'</' + 'script>',
'',
'<Button.Provider',
intent !== 'neutral' && ` intent="${intent}"`,
color && ` color="${color}"`,
buttonType !== 'button' && ` type="${buttonType}"`,
disabled && ' disabled',
loading && ' loading',
loadingText && ` loadingText="${loadingText}"`,
ariaLabel && ` aria-label="${ariaLabel}"`,
`>${label}</Button.Provider>`
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Button } from '$uix/eidos/components/button';",
'</' + 'script>',
'',
'<Button',
intent !== 'neutral' && ` intent="${intent}"`,
color && ` color="${color}"`,
variant !== 'solid' && ` variant="${variant}"`,
size !== 'md' && ` size="${size}"`,
rounded && ` rounded="${rounded}"`,
shape !== 'rounded' && ` shape="${shape}"`,
block && ' block',
iconOnly && ' iconOnly',
loading && ' loading',
loadingText && ` loadingText="${loadingText}"`,
spinnerPlacement !== 'start' && ` spinnerPlacement="${spinnerPlacement}"`,
disabled && ' disabled',
ariaLabel && ` aria-label="${ariaLabel}"`,
`>${label}</Button>`
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Form · Button</div>
<h1 data-uix-page-title>Button</h1>
<p data-uix-page-lede>
Single-press action surface. One doctrinal event
(<code>commit-action</code>) with intent bound to the consumer's
prop — drives the perceptual signature (sound + haptic) per the
canon. 6 visual variants, 8 colors (3 hierarchy + 5 evaluative
intents auto-cascading), 5 sizes, optional <code>loading</code>
state with a built-in spinner part, <code>iconOnly</code> square
aspect, <code>block</code> stretch. Native button keyboard
(Enter / Space). Field-aware: <code>disabled</code> OR-merges
with the enclosing <code>Field</code>. Supports Radix-style
<code>asChild</code> composition via the <code>child</code>
snippet.
</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>variants</span>{variants.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>sizes</span>{sizes.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href={buttonMorfo.apg} target="_blank" rel="noreferrer">button ↗</a>
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>presses</span>{pressCount}
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<Button
{intent}
{color}
type={buttonType}
{variant}
{size}
{rounded}
{shape}
{block}
{iconOnly}
{loading}
loadingText={loadingText || undefined}
{spinnerPlacement}
{disabled}
aria-label={ariaLabel || (iconOnly ? label : undefined)}
onPress={handlePress}
onclick={handlePress}
>
{#snippet icon()}
{#if StartIconComp}{@const C = StartIconComp}<C />{/if}
{/snippet}
{#snippet endIcon()}
{#if EndIconComp}{@const C = EndIconComp}<C />{/if}
{/snippet}
{label}
</Button>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click the button to fire commit-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>intent</span> {intent}
<span data-uix-stage-trace-key style="margin-inline-start: 0.5rem;">presses</span> {pressCount}
</span>
</div>
</div>
<!-- Tabs -->
<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 === 'gallery'} onclick={() => (tab = 'gallery')}>
Gallery <span data-uix-tab-count>{variants.length}×{gallery.length}</span>
</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>2</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {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>
Props grouped by the layer that owns them:
<span data-uix-layer-badge="soma">soma</span> for intent /
disabled / loading / native button attrs;
<span data-uix-layer-badge="eidos">eidos</span> for variant /
size / rounded / block / iconOnly. Semantic events live in the
<a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
</p>
<!-- ── Soma · semantics ─────────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Semantics
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>intent <span data-uix-control-hint>doctrinal · drives sema</span></span>
<span data-uix-chips role="radiogroup">
{#each intents as opt (opt)}
<button data-uix-chip data-active={intent === opt} onclick={() => (intent = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color <span data-uix-control-hint>only applies when intent=neutral</span></span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={color === undefined} onclick={() => (color = undefined)}>default</button>
{#each colors as opt (opt)}
<button data-uix-chip data-active={color === opt} onclick={() => (color = opt)} disabled={intent !== 'neutral'}>{opt}</button>
{/each}
</span>
</label>
</div>
<!-- ── Soma · behavior ──────────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Behavior + native
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>type</span>
<span data-uix-chips role="radiogroup">
{#each types as opt (opt)}
<button data-uix-chip data-active={buttonType === opt} onclick={() => (buttonType = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled <span data-uix-control-hint>Field-OR</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>loading <span data-uix-control-hint>renders Spinner part</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={loading} />
<span data-uix-switch-label>{loading ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>loadingText <span data-uix-control-hint>replace label while loading</span></span>
<input type="text" bind:value={loadingText} placeholder="(empty = keep children)" />
</label>
<label data-uix-control>
<span data-uix-control-label>aria-label <span data-uix-control-hint>required when iconOnly</span></span>
<input type="text" bind:value={ariaLabel} placeholder="(empty unless iconOnly)" />
</label>
</div>
<!-- ── Eidos · visual ───────────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> Visual treatment
</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 opt (opt)}
<button data-uix-chip data-active={variant === opt} onclick={() => (variant = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as opt (opt)}
<button data-uix-chip data-active={size === opt} onclick={() => (size = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>rounded</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={rounded === undefined} onclick={() => (rounded = undefined)}>default</button>
{#each roundeds as opt (opt)}
<button data-uix-chip data-active={rounded === opt} onclick={() => (rounded = opt)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>shape</span>
<span data-uix-chips role="radiogroup">
{#each SHAPE_FAMILIES as sh (sh)}
<button data-uix-chip data-active={shape === sh} onclick={() => (shape = sh)}>{sh}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>block <span data-uix-control-hint>stretch to fill</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>iconOnly <span data-uix-control-hint>square aspect</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={iconOnly} />
<span data-uix-switch-label>{iconOnly ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>spinnerPlacement</span>
<span data-uix-chips role="radiogroup">
{#each ['start', 'end'] as opt (opt)}
<button data-uix-chip data-active={spinnerPlacement === opt} onclick={() => (spinnerPlacement = opt as 'start' | 'end')}>{opt}</button>
{/each}
</span>
</label>
</div>
<!-- ── Icon slots ──────────────────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> Icon slots
<span style="font-weight: normal; color: var(--uix-text-muted);">
· when iconOnly is on, the start icon is the visible glyph and the label becomes sr-only
</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>icon <span data-uix-control-hint>start / sole glyph when iconOnly</span></span>
<span data-uix-chips role="radiogroup">
{#each iconKeys as key (key)}
<button data-uix-chip data-active={startIcon === key} onclick={() => (startIcon = key)}>{key}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>endIcon <span data-uix-control-hint>collapsed when iconOnly</span></span>
<span data-uix-chips role="radiogroup">
{#each iconKeys as key (key)}
<button data-uix-chip data-active={endIcon === key} onclick={() => (endIcon = key)} disabled={iconOnly}>{key}</button>
{/each}
</span>
</label>
</div>
<!-- ── Demo content ─────────────────────────────────────────────── -->
<div data-uix-subsection-head>
Demo content
<span style="font-weight: normal; color: var(--uix-text-muted);">
· children literal · stays sr-only when iconOnly
</span>
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>label</span>
<input type="text" bind:value={label} />
</label>
</div>
<!-- ── Code snippets ───────────────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · ARIA + commit-action + loading state</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 · adds variant, size, rounded, block, iconOnly, spinnerPlacement</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'gallery'}
<section data-uix-section>
<h2 data-uix-section-title>Variants × colors</h2>
<p data-uix-section-desc>
Every <span data-uix-layer-badge="eidos">eidos</span> variant across all 8 color roles.
Hierarchy (<code>primary</code> / <code>secondary</code> / <code>neutral</code>) rides the
<code>color</code> prop; the evaluative palette (<code>affirm</code> / <code>fulfill</code>
/ <code>risk</code> / <code>threat</code> / <code>loss</code>) rides <code>intent</code>,
which wins on <code>data-color</code>.
</p>
<div style="display: flex; flex-direction: column; gap: 0.85rem;">
{#each variants as v}
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<span
style="inline-size: 60px; flex-shrink: 0; font-family: monospace; font-size: 12px; color: var(--uix-text-faint);"
>{v}</span
>
{#each gallery as c}
<Button variant={v} color={c.color} intent={c.intent} size="sm">{c.name}</Button>
{/each}
</div>
{/each}
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
Single-part component: the root IS the <code>&lt;button&gt;</code>.
The Spinner is rendered automatically when <code>loading=true</code>
— consumers can override the glyph via the <code>spinner</code>
snippet prop on the root, or compose <code>Button.Spinner</code>
explicitly inside a <code>child</code> snippet for total control.
</p>
<div data-uix-subsection-head>Button (root)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">id</td><td class="type">string</td><td class="default empty">auto</td><td>Stable identifier.</td></tr>
<tr><td class="name">intent</td><td class="type">'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss'</td><td class="default">'neutral'</td><td>Doctrinal consequence intent. Drives sema perceptual signature; non-neutral wins over <code>color</code>.</td></tr>
<tr><td class="name">color</td><td class="type">'primary' | 'secondary' | 'neutral'</td><td class="default empty">— (resolves to 'primary' when intent='neutral')</td><td>Hierarchical override. Only applies when <code>intent='neutral'</code>.</td></tr>
<tr><td class="name">type</td><td class="type">'button' | 'submit' | 'reset'</td><td class="default">'button'</td><td>Native button type.</td></tr>
<tr><td class="name">disabled <span data-uix-tag data-kind="field">field-OR</span></td><td class="type">boolean</td><td class="default">false</td><td>OR-merges with Field.Provider.</td></tr>
<tr><td class="name">loading</td><td class="type">boolean</td><td class="default">false</td><td>While true, gates click handler, sets <code>aria-busy</code>, renders Spinner part.</td></tr>
<tr><td class="name">loadingText</td><td class="type">string</td><td class="default empty">—</td><td>Replace children visibly while loading (children remain sr-only).</td></tr>
<tr><td class="name">spinnerPlacement <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'start' | 'end'</td><td class="default">'start'</td><td>Where to render the spinner.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string</td><td class="default empty">—</td><td>Accessible name. <strong>Required</strong> when <code>iconOnly</code>.</td></tr>
<tr><td class="name">onPress</td><td class="type">{`({ intent }) => void`}</td><td class="default empty">—</td><td>Read-only feedback fired alongside <code>commit-action</code>. For business logic use native <code>onclick</code>.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain'</td><td class="default">'solid'</td><td>Visual treatment.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'xs' | 'sm' | 'md' | 'lg' | 'xl'&gt;</td><td class="default">'md'</td><td>T-shirt scale.</td></tr>
<tr><td class="name">rounded <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'sm' | 'md' | 'lg' | 'xl' | 'full'&gt;</td><td class="default empty">inherits from size</td><td>Corner radius override.</td></tr>
<tr><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">false</td><td>Stretch to fill parent inline-size.</td></tr>
<tr><td class="name">iconOnly <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">false</td><td>Square aspect, padding-inline collapsed.</td></tr>
<tr><td class="name">spinner <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">Snippet</td><td class="default empty">—</td><td>Override the default spinner glyph.</td></tr>
<tr><td class="name">child</td><td class="type">{`Snippet<[{ loading, disabled, props }]>`}</td><td class="default empty">—</td><td>Slot composition (Radix asChild pattern). Spread <code>props</code> into your own element.</td></tr>
<tr><td class="name">…native button attrs</td><td class="type">PrimitiveButtonAttributes</td><td class="default empty">—</td><td>onclick, form, formaction, autofocus, etc.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Button.Spinner</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">id</td><td class="type">string</td><td class="default empty">auto</td><td>—</td></tr>
<tr><td class="name">children</td><td class="type">Snippet</td><td class="default empty">default ring SVG</td><td>Override the spinner glyph.</td></tr>
<tr><td class="name">…native span attrs</td><td class="type">PrimitiveSpanAttributes</td><td class="default empty">—</td><td>—</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference parity</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Feature</th><th>Radix</th><th>bits-ui</th><th>shadcn</th><th>Chakra v3</th><th>Mantine</th><th>MUI</th><th>Eidos (this)</th></tr></thead>
<tbody>
<tr><td>Component exists</td><td>—</td><td>✓ (thin)</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td>variant count</td><td>—</td><td>—</td><td>6</td><td>7</td><td>8</td><td>3</td><td>6</td></tr>
<tr><td>color palette</td><td>—</td><td>—</td><td>implicit</td><td>palette token</td><td>color</td><td>color</td><td>8 (3 hierarchy + 5 intents)</td></tr>
<tr><td>sizes</td><td>—</td><td>—</td><td>4</td><td>6</td><td>5</td><td>3</td><td>5</td></tr>
<tr><td>loading state</td><td>—</td><td>—</td><td>—</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td>loadingText</td><td>—</td><td>—</td><td>—</td><td>✓</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>spinnerPlacement</td><td>—</td><td>—</td><td>—</td><td>✓</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>iconOnly square</td><td>—</td><td>—</td><td>'icon' size</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>block / fullWidth</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td><td>✓</td><td>✓</td></tr>
<tr><td>rounded scale</td><td>—</td><td>—</td><td>—</td><td>—</td><td>radius</td><td>—</td><td>✓</td></tr>
<tr><td>asChild / Slot</td><td>✓</td><td>✓</td><td>✓</td><td>✓</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>commit-action semantic event</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</td></tr>
<tr><td>per-intent perceptual signature</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>✓</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>
2 parts (Provider + optional Spinner), 1 event
(<code>commit-action</code> with intent fromProp). Sequence
<em>pre</em> — perceptual signal lands at click, before the
consumer's onclick. Source:
<code>src/uix/morfo/components/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">"{buttonMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{buttonMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{buttonMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={buttonMorfo.apg} target="_blank" rel="noreferrer">{buttonMorfo.apg}</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{buttonMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{buttonMorfo.events?.length ?? 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 (part.kebab)}
<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 buttonMorfo.parts as rawPart (rawPart.kebab)}
{@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string; part?: string }; severity?: string }>; keyboard?: ReadonlyArray<{ key: string; action: string }> }}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{@const keyboard = partAny.keyboard ?? []}
{#if dataAttrs.length || ariaAttrs.length || keyboard.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr (attr.attr)}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Condition</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a (a.attr)}
<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 keyboard.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>key</th><th>action</th></tr></thead>
<tbody>
{#each keyboard as kb (kb.key)}
<tr>
<td><span data-uix-kbd>{kb.key === ' ' ? 'Space' : kb.key}</span></td>
<td class="default">{kb.action}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
<div data-uix-subsection-head>Events declaration</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
<code>commit-action</code> fires with sequence <em>pre</em> so
the perceptual signal lands at the moment of the click; the
consumer's onclick may then run async work without blocking.
Intent is bound to the <code>intent</code> prop; supported
intents are the full commit-friendly set (neutral / affirm /
fulfill / risk / threat / loss).
</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>target</th><th>prewrite</th><th>commit</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 ?? '?'} (default ${(intentDecl as { default?: string }).default ?? '?'})` : '—'}
<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>
<td class="default">{action.prewrite.length ? action.prewrite.map((p) => `${p.attr}=${p.value}`).join(', ') : '—'}</td>
<td class="default">{action.commit ? `${action.commit.attr}=${action.commit.value}` : '—'}</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-action</code> from the
<code>commit</code> family. Each press fires it with sequence
<em>pre</em> — the perceptual signal lands at the click moment,
before any async work in the consumer's onclick. Intent is per-
button via <code>fromProp:intent</code> and modulates the
non-visual signature (sound timbre + haptic curve) via
<code>SEMA_MAP.intents</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 sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const effectiveIntent =
typeof intentDecl === 'string'
? intentDecl
: intentDecl
? intent
: undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'} <span style="color: var(--uix-text-faint)">(live)</span></td>
<td>
<button
data-uix-play
onclick={() => {
const target = stageRef?.querySelector('[data-button]') as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: sem.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>
Source: <code>src/uix/eidos/components/button/button.css</code>.
Recipe tokens declared in <code>src/uix/eidos/lib/recipes/base.ts</code>
under the <code>button</code> key. Six variant slices map the
resolved palette to <code>bg / fg / border / hover-*</code>
private vars; the size cascade overrides height / padding /
gap / font-size / icon-size; the per-color cascade re-binds
the palette block (8 system tokens).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>Concern</th></tr></thead>
<tbody>
<tr><td class="name">[data-button]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Base layout: inline-flex, gap, height, padding, border, radius, transitions.</td></tr>
<tr><td class="name">[data-button][data-size='xs' | 'sm' | 'md' | 'lg' | 'xl']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Size token cascade.</td></tr>
<tr><td class="name">[data-button][data-color='X']</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Per-color palette cascade (8 system tokens).</td></tr>
<tr><td class="name">[data-button][data-variant='solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Variant slice maps palette → bg/fg/border/hover.</td></tr>
<tr><td class="name">[data-button][data-rounded='X']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Corner radius override.</td></tr>
<tr><td class="name">[data-button][data-block]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>display: flex + inline-size: 100%.</td></tr>
<tr><td class="name">[data-button][data-icon-only]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Square aspect, padding-inline: 0.</td></tr>
<tr><td class="name">[data-button][data-loading]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Cursor progress + pointer-events lock.</td></tr>
<tr><td class="name">[data-button][data-disabled]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Opacity + not-allowed cursor.</td></tr>
<tr><td class="name">[data-button]:hover / :active / :focus-visible</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Interaction states.</td></tr>
<tr><td class="name">[data-button-spinner]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Rotating ring SVG container.</td></tr>
<tr><td class="name">.eidos-button-spinner-ring</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>CSS @keyframes rotate animation.</td></tr>
<tr><td class="name">.eidos-button-body</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Inline-flex wrapper for children + gap.</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={buttonMorfo.apg} target="_blank" rel="noreferrer">button pattern</a>.
Renders a native <code>&lt;button&gt;</code> — gets the full
native keyboard + focus model. <code>iconOnly</code> consumers
MUST set <code>aria-label</code>. While loading, the button
exposes <code>aria-busy='true'</code> so screen readers
announce the deferred state.
</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>Enter</span></td><td>Activate (native).</td></tr>
<tr><td><span data-uix-kbd>Space</span></td><td>Activate (native).</td></tr>
<tr><td><span data-uix-kbd>Tab</span> / <span data-uix-kbd>Shift+Tab</span></td><td>Move focus to next / previous focusable.</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">provider</td><td>type</td><td class="type">{`{type}`} (default "button" — explicit to avoid implicit submit in forms)</td></tr>
<tr><td class="name">provider</td><td>data-state</td><td class="type">"idle" | "loading"</td></tr>
<tr><td class="name">provider</td><td>data-color</td><td class="type">resolved color (intent wins when evaluative)</td></tr>
<tr><td class="name">provider</td><td>data-loading</td><td class="type">"true" when loading</td></tr>
<tr><td class="name">provider</td><td>data-disabled</td><td class="type">"true" when disabled</td></tr>
<tr><td class="name">provider</td><td>aria-busy</td><td class="type">"true" when loading</td></tr>
<tr><td class="name">provider</td><td>aria-disabled</td><td class="type">"true" when disabled</td></tr>
<tr><td class="name">provider</td><td>aria-label</td><td class="type">{`{aria-label}`} when set (required for iconOnly)</td></tr>
<tr><td class="name">spinner</td><td>aria-hidden</td><td class="type">"true" (decorative — aria-busy on provider conveys state to AT)</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.