feat(eidos): add Skeleton and Spinner loader primitives

Two pure-eidos loader components with morfos at scope `['eidos']`
(no soma backing, no semantic events).

Skeleton — content placeholder:
- 3 shapes: rect (default), circle, text (multi-line with staggered widths)
- 5 sizes (xs..xl), 8 color accents
- Optional shimmer animation via CSS `background-position` keyframes
- Single `<div role="status" aria-busy="true">` with translated aria-label
- Reduced-motion freezes animation, keeps visual

Spinner — indeterminate progress:
- 3 variants: ring (rotating arc), dots (bouncing), bars (stretching)
- 5 sizes, 8 colors
- Optional inline label via children snippet
- `role="status"` + `aria-live="polite"` for SR announcements
- Reduced-motion freezes animation, keeps visual

Files:
- morfo: skeleton.ts, spinner.ts (scope eidos, no events)
- eidos: full component + types + css + langs + index per component
- langs: skeleton/spinner under componentLangs barrel
- demos: canonical 6-tab depth + composition examples (avatar+text card)
- layout: sidebar additions under Status group

Note: also includes responsive shell additions to web/routes/uix/uix.css
that surfaced from an external session — same file, leaving them in.

Reference libraries reviewed (Mantine, Chakra v3, Radix Themes, MUI):
all expose the same shape/variant/size/color surface. shadcn Skeleton uses
the same pulse animation. No surprises in our API.

svelte-check: 0 errors in skeleton/spinner files.

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

@ -0,0 +1,12 @@
// Skeleton — content placeholder primitive.
//
// import { Skeleton } from '$uix/eidos/components/skeleton';
//
// <Skeleton width="100%" height={40} />
// <Skeleton shape="circle" size="lg" />
// <Skeleton shape="text" lines={3} />
import Skeleton from './skeleton.svelte';
export { Skeleton };
export default Skeleton;
export type { SkeletonProps, SkeletonShape, SkeletonSize, SkeletonColor } from './types';

@ -0,0 +1,4 @@
/** Idlangref constants for the Skeleton component. */
export const SKELETON_LANGS = {
LABEL: '#?components.skeleton.label|Loading'
} as const;

@ -0,0 +1,144 @@
/*
* Skeleton — placeholder block.
*
* Shapes:
* - rect → rounded rectangle, sized by --_skeleton-{w,h}
* - circle → 1:1 ratio, fully rounded
* - text → stacked thin bars at staggered widths
*
* Animation: optional shimmer wave via background-position keyframes.
*/
[data-skeleton] {
--_skeleton-bg: color-mix(in srgb, var(--color-neutral-track) 60%, transparent);
--_skeleton-highlight: color-mix(in srgb, var(--color-neutral-track) 100%, white 10%);
--_skeleton-size: 1rem;
--_skeleton-radius: var(--radius-md);
--_skeleton-duration: 1.4s;
display: block;
background: var(--_skeleton-bg);
border-radius: var(--_skeleton-radius);
inline-size: 100%;
block-size: var(--_skeleton-size);
overflow: hidden;
position: relative;
}
/* Sizes — intrinsic block-size when the consumer doesn't override. */
[data-skeleton][data-size='xs'] {
--_skeleton-size: 0.5rem;
}
[data-skeleton][data-size='sm'] {
--_skeleton-size: 0.75rem;
}
[data-skeleton][data-size='md'] {
--_skeleton-size: 1rem;
}
[data-skeleton][data-size='lg'] {
--_skeleton-size: 1.5rem;
}
[data-skeleton][data-size='xl'] {
--_skeleton-size: 2rem;
}
/* ── Shapes ────────────────────────────────────────────────────────── */
[data-skeleton][data-shape='rect'] {
--_skeleton-radius: var(--radius-md);
}
[data-skeleton][data-shape='circle'] {
--_skeleton-radius: 50%;
aspect-ratio: 1 / 1;
inline-size: var(--_skeleton-size);
}
[data-skeleton][data-shape='text'] {
display: flex;
flex-direction: column;
gap: calc(var(--_skeleton-size) * 0.5);
background: transparent;
overflow: visible;
block-size: auto;
}
[data-skeleton-line] {
display: block;
block-size: var(--_skeleton-size, 1rem);
border-radius: var(--radius-sm);
background: var(--_skeleton-bg);
overflow: hidden;
position: relative;
}
/* ── Color accents ─────────────────────────────────────────────────── */
[data-skeleton][data-color='primary'] {
--_skeleton-bg: color-mix(in srgb, var(--color-primary-track) 50%, transparent);
--_skeleton-highlight: color-mix(in srgb, var(--color-primary-track) 90%, white 8%);
}
[data-skeleton][data-color='secondary'] {
--_skeleton-bg: color-mix(in srgb, var(--color-secondary-track) 50%, transparent);
--_skeleton-highlight: color-mix(in srgb, var(--color-secondary-track) 90%, white 8%);
}
[data-skeleton][data-color='affirm'] {
--_skeleton-bg: color-mix(in srgb, var(--color-affirm-track) 50%, transparent);
}
[data-skeleton][data-color='fulfill'] {
--_skeleton-bg: color-mix(in srgb, var(--color-fulfill-track) 50%, transparent);
}
[data-skeleton][data-color='risk'] {
--_skeleton-bg: color-mix(in srgb, var(--color-risk-track) 50%, transparent);
}
[data-skeleton][data-color='threat'] {
--_skeleton-bg: color-mix(in srgb, var(--color-threat-track) 50%, transparent);
}
[data-skeleton][data-color='loss'] {
--_skeleton-bg: color-mix(in srgb, var(--color-loss-track) 50%, transparent);
}
/* ── Animation (shimmer wave) ──────────────────────────────────────── */
[data-skeleton][data-animated] {
background-image: linear-gradient(
90deg,
var(--_skeleton-bg) 0%,
var(--_skeleton-highlight) 50%,
var(--_skeleton-bg) 100%
);
background-size: 200% 100%;
animation: skeleton-shimmer var(--_skeleton-duration) ease-in-out infinite;
}
[data-skeleton][data-shape='text'][data-animated] {
background-image: none;
animation: none;
}
[data-skeleton][data-shape='text'][data-animated] [data-skeleton-line] {
background-image: linear-gradient(
90deg,
var(--_skeleton-bg) 0%,
var(--_skeleton-highlight) 50%,
var(--_skeleton-bg) 100%
);
background-size: 200% 100%;
animation: skeleton-shimmer var(--_skeleton-duration) ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-skeleton][data-animated],
[data-skeleton][data-shape='text'][data-animated] [data-skeleton-line] {
animation: none;
background-image: none;
}
}

@ -0,0 +1,81 @@
<script lang="ts">
/**
* Eidos `<Skeleton>` — content placeholder shown while data loads.
*
* Usage:
* - `<Skeleton width="100%" height={40} />`
* - `<Skeleton shape="circle" size="lg" />`
* - `<Skeleton shape="text" lines={3} />`
*
* No morfo events — pure visual. The single `<div role="status">`
* carries `aria-busy="true"` and a translated `aria-label="Loading"`
* so SR users get the right announcement when this enters the DOM.
*/
import './skeleton.css';
import { ActiveEidos } from '$uix/eidos';
import { getActiveUix } from '$active-uix';
import { SKELETON_LANGS } from './langs';
import type { SkeletonProps } from './types';
let {
shape = 'rect',
size = 'md',
color = 'neutral',
lines = 1,
animated = true,
'aria-label': ariaLabel,
children,
...rest
}: SkeletonProps = $props();
const eidos = ActiveEidos.require();
const uix = getActiveUix();
const resolvedShape = $derived(eidos.resolve(shape, 'rect'));
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedLabel = $derived(ariaLabel ?? uix.langs.ts(SKELETON_LANGS.LABEL));
// Stagger widths for multi-line text skeletons so the block reads as
// a paragraph rather than a solid rectangle. Last line always shorter.
const lineWidths = $derived.by(() => {
if (resolvedShape !== 'text' || lines < 2) return undefined;
const widths: string[] = [];
for (let i = 0; i < lines; i++) {
const last = i === lines - 1;
widths.push(last ? '60%' : i === 0 ? '100%' : '94%');
}
return widths;
});
</script>
{#if resolvedShape === 'text' && lineWidths}
<div
data-skeleton=""
data-shape="text"
data-size={resolvedSize}
data-color={color}
data-animated={animated ? '' : undefined}
role="status"
aria-busy="true"
aria-label={resolvedLabel}
{...rest}
>
{#each lineWidths as w, i (i)}
<span data-skeleton-line="" style="inline-size: {w};"></span>
{/each}
</div>
{:else}
<div
data-skeleton=""
data-shape={resolvedShape}
data-size={resolvedSize}
data-color={color}
data-animated={animated ? '' : undefined}
role="status"
aria-busy="true"
aria-label={resolvedLabel}
{...rest}
>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
import type { Snippet } from 'svelte';
import type { ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { BoxProps } from '../box/types';
export type SkeletonShape = 'rect' | 'circle' | 'text';
export type SkeletonSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
export type SkeletonColor = ColorRole;
/**
* Props for `<Skeleton>`.
*
* Composes through `<Box>` so every layout prop (width, height,
* gridColumn, …) flows through. The `shape` prop only adjusts the
* recipe (border-radius, intrinsic block-size). For text skeletons,
* pass `lines={n}` to stack n text bars stagger-widthed.
*/
export type SkeletonProps = Omit<BoxProps, 'display'> & {
/** Visual shape. @default 'rect' */
shape?: ResponsiveProp<SkeletonShape>;
/** Reservation size. @default 'md' (only meaningful when no width/height set) */
size?: ResponsiveProp<SkeletonSize>;
/** Accent palette (placeholder background lightness). @default 'neutral' */
color?: SkeletonColor;
/**
* Number of stacked bars when `shape='text'`. Each bar's width
* decreases slightly so the block looks like a paragraph.
* @default 1
*/
lines?: number;
/** Animate the shimmer pulse. @default true */
animated?: boolean;
/** Override the SR text. Defaults to a translated `'Loading'`. */
'aria-label'?: string;
children?: Snippet;
};

@ -0,0 +1,12 @@
// Spinner — indeterminate progress indicator.
//
// import { Spinner } from '$uix/eidos/components/spinner';
//
// <Spinner />
// <Spinner variant="dots" size="sm" color="affirm" />
// <Spinner>Loading…</Spinner>
import Spinner from './spinner.svelte';
export { Spinner };
export default Spinner;
export type { SpinnerProps, SpinnerSize, SpinnerColor, SpinnerVariant } from './types';

@ -0,0 +1,4 @@
/** Idlangref constants for the Spinner component. */
export const SPINNER_LANGS = {
LABEL: '#?components.spinner.label|Loading'
} as const;

@ -0,0 +1,194 @@
/*
* Spinner — indeterminate progress.
*
* Three variants share the same outer chrome:
* - ring → rotating arc made by a conic-gradient mask
* - dots → 3 dots, staggered scale animation
* - bars → 4 bars, staggered height animation
*
* Reduced motion: animations stop but the element stays visible so the
* "loading" affordance is preserved.
*/
[data-spinner] {
--_spinner-size: 1.25rem;
--_spinner-thickness: 2px;
--_spinner-duration: 0.9s;
--_spinner-color: var(--color-primary-text);
--_spinner-track: color-mix(in srgb, var(--color-primary-track) 60%, transparent);
--_spinner-gap: var(--space-2);
display: inline-flex;
align-items: center;
gap: var(--_spinner-gap);
color: var(--_spinner-color);
}
[data-spinner][data-size='xs'] {
--_spinner-size: 0.75rem;
--_spinner-thickness: 1.5px;
}
[data-spinner][data-size='sm'] {
--_spinner-size: 1rem;
--_spinner-thickness: 2px;
}
[data-spinner][data-size='md'] {
--_spinner-size: 1.25rem;
--_spinner-thickness: 2px;
}
[data-spinner][data-size='lg'] {
--_spinner-size: 1.75rem;
--_spinner-thickness: 3px;
}
[data-spinner][data-size='xl'] {
--_spinner-size: 2.5rem;
--_spinner-thickness: 4px;
}
/* Color tokens — accent + track. */
[data-spinner][data-color='secondary'] {
--_spinner-color: var(--color-secondary-text);
--_spinner-track: color-mix(in srgb, var(--color-secondary-track) 60%, transparent);
}
[data-spinner][data-color='neutral'] {
--_spinner-color: var(--color-neutral-text);
--_spinner-track: color-mix(in srgb, var(--color-neutral-track) 60%, transparent);
}
[data-spinner][data-color='affirm'] {
--_spinner-color: var(--color-affirm-text);
--_spinner-track: color-mix(in srgb, var(--color-affirm-track) 60%, transparent);
}
[data-spinner][data-color='fulfill'] {
--_spinner-color: var(--color-fulfill-text);
--_spinner-track: color-mix(in srgb, var(--color-fulfill-track) 60%, transparent);
}
[data-spinner][data-color='risk'] {
--_spinner-color: var(--color-risk-text);
--_spinner-track: color-mix(in srgb, var(--color-risk-track) 60%, transparent);
}
[data-spinner][data-color='threat'] {
--_spinner-color: var(--color-threat-text);
--_spinner-track: color-mix(in srgb, var(--color-threat-track) 60%, transparent);
}
[data-spinner][data-color='loss'] {
--_spinner-color: var(--color-loss-text);
--_spinner-track: color-mix(in srgb, var(--color-loss-track) 60%, transparent);
}
/* ── Ring variant ──────────────────────────────────────────────────── */
[data-spinner][data-variant='ring'] [data-spinner-track] {
display: block;
inline-size: var(--_spinner-size);
block-size: var(--_spinner-size);
border-radius: 50%;
border: var(--_spinner-thickness) solid var(--_spinner-track);
border-top-color: var(--_spinner-color);
animation: spinner-rotate var(--_spinner-duration) linear infinite;
}
/* ── Dots variant ──────────────────────────────────────────────────── */
[data-spinner][data-variant='dots'] [data-spinner-track] {
display: inline-flex;
align-items: center;
gap: calc(var(--_spinner-size) * 0.2);
block-size: var(--_spinner-size);
}
[data-spinner][data-variant='dots'] [data-spinner-dot] {
display: block;
inline-size: calc(var(--_spinner-size) * 0.3);
block-size: calc(var(--_spinner-size) * 0.3);
border-radius: 50%;
background: var(--_spinner-color);
animation: spinner-bounce 1.2s ease-in-out infinite both;
}
[data-spinner][data-variant='dots'] [data-spinner-dot]:nth-child(1) {
animation-delay: -0.32s;
}
[data-spinner][data-variant='dots'] [data-spinner-dot]:nth-child(2) {
animation-delay: -0.16s;
}
/* ── Bars variant ──────────────────────────────────────────────────── */
[data-spinner][data-variant='bars'] [data-spinner-track] {
display: inline-flex;
align-items: center;
gap: calc(var(--_spinner-size) * 0.12);
block-size: var(--_spinner-size);
}
[data-spinner][data-variant='bars'] [data-spinner-bar] {
display: block;
inline-size: calc(var(--_spinner-size) * 0.18);
block-size: var(--_spinner-size);
border-radius: var(--radius-sm);
background: var(--_spinner-color);
animation: spinner-stretch 1s ease-in-out infinite;
}
[data-spinner][data-variant='bars'] [data-spinner-bar]:nth-child(1) {
animation-delay: -1s;
}
[data-spinner][data-variant='bars'] [data-spinner-bar]:nth-child(2) {
animation-delay: -0.85s;
}
[data-spinner][data-variant='bars'] [data-spinner-bar]:nth-child(3) {
animation-delay: -0.7s;
}
[data-spinner][data-variant='bars'] [data-spinner-bar]:nth-child(4) {
animation-delay: -0.55s;
}
/* ── Label ─────────────────────────────────────────────────────────── */
[data-spinner-label] {
font-size: var(--font-size-sm);
color: var(--color-content-muted);
}
/* ── Keyframes ─────────────────────────────────────────────────────── */
@keyframes spinner-rotate {
to {
transform: rotate(360deg);
}
}
@keyframes spinner-bounce {
0%,
80%,
100% {
transform: scale(0.5);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@keyframes spinner-stretch {
0%,
40%,
100% {
transform: scaleY(0.4);
}
20% {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
[data-spinner] [data-spinner-track],
[data-spinner] [data-spinner-dot],
[data-spinner] [data-spinner-bar] {
animation: none;
}
[data-spinner][data-variant='ring'] [data-spinner-track] {
border-top-color: var(--_spinner-track);
opacity: 0.6;
}
}

@ -0,0 +1,64 @@
<script lang="ts">
/**
* Eidos `<Spinner>` — indeterminate progress indicator.
*
* <Spinner /> // default ring, size md, primary
* <Spinner variant="dots" size="sm" color="affirm" />
* <Spinner>Loading…</Spinner> // with inline label
*
* No morfo events — pure visual. Single `<div role="status">` with a
* decorative track inside. Reduced-motion freezes the animation but
* keeps the visual to preserve the loading affordance.
*/
import './spinner.css';
import { ActiveEidos } from '$uix/eidos';
import { getActiveUix } from '$active-uix';
import { SPINNER_LANGS } from './langs';
import type { SpinnerProps } from './types';
let {
variant = 'ring',
size = 'md',
color = 'primary',
'aria-label': ariaLabel,
children,
...rest
}: SpinnerProps = $props();
const eidos = ActiveEidos.require();
const uix = getActiveUix();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedLabel = $derived(ariaLabel ?? uix.langs.ts(SPINNER_LANGS.LABEL));
</script>
<div
data-spinner=""
data-variant={variant}
data-size={resolvedSize}
data-color={color}
role="status"
aria-live="polite"
aria-label={resolvedLabel}
{...rest}
>
{#if 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>
{:else if 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>
{:else}
<span data-spinner-track="" aria-hidden="true"></span>
{/if}
{#if children}
<span data-spinner-label="">{@render children()}</span>
{/if}
</div>

@ -0,0 +1,28 @@
import type { Snippet } from 'svelte';
import type { ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { BoxProps } from '../box/types';
export type SpinnerSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
export type SpinnerColor = ColorRole;
export type SpinnerVariant = 'ring' | 'dots' | 'bars';
/**
* Props for `<Spinner>`.
*
* Composes through `<Box>` for layout primitives (gap, gridColumn, …).
* The `variant` prop toggles between three canonical visual styles —
* a rotating ring (default, lightest), bouncing dots (medium weight),
* scrolling bars (most attention).
*/
export type SpinnerProps = Omit<BoxProps, 'display'> & {
/** Visual style. @default 'ring' */
variant?: SpinnerVariant;
/** Spinner size. @default 'md' */
size?: ResponsiveProp<SpinnerSize>;
/** Accent palette. @default 'primary' */
color?: SpinnerColor;
/** Override the SR text. Defaults to a translated `'Loading'`. */
'aria-label'?: string;
/** Optional visible label rendered next to the spinner. */
children?: Snippet;
};

@ -45,7 +45,9 @@ import { ratingGroupLangs } from './rating-group';
import { scrollAreaLangs } from './scroll-area';
import { searchFieldLangs } from './search-field';
import { selectLangs } from './select';
import { skeletonLangs } from './skeleton';
import { sliderLangs } from './slider';
import { spinnerLangs } from './spinner';
import { splitterLangs } from './splitter';
import { stepperLangs } from './stepper';
import { switchLangs } from './switch';
@ -119,7 +121,9 @@ export const componentLangs = {
'scroll-area': scrollAreaLangs,
'search-field': searchFieldLangs,
select: selectLangs,
skeleton: skeletonLangs,
slider: sliderLangs,
spinner: spinnerLangs,
splitter: splitterLangs,
stepper: stepperLangs,
switch: switchLangs,

@ -0,0 +1,12 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the skeleton component. Merged under
* `components.skeleton.*` by `ActiveUix` (via `componentLangs` barrel).
*/
export const skeletonLangs = {
label: {
es: 'Cargando',
en: 'Loading'
}
} satisfies LangNode;

@ -0,0 +1,12 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the spinner component. Merged under
* `components.spinner.*` by `ActiveUix` (via `componentLangs` barrel).
*/
export const spinnerLangs = {
label: {
es: 'Cargando',
en: 'Loading'
}
} satisfies LangNode;

@ -0,0 +1,47 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Skeleton — content placeholder shown while data loads.
*
* Eidos-native primitive: pure visual / a11y surface, no behavior, no
* state, no events. Renders a single `<div role="status">` carrying an
* `aria-label` so SR users get "loading" announced. Shape (rect / circle /
* text) toggles CSS recipe geometry via `data-shape`.
*
* Justification for 0-event surface: Skeleton has no commit/emerge/shift
* semantic — it disappears when the consumer swaps it for real content.
* Adding events would manufacture meaning the primitive doesn't carry.
* Same precedent as AspectRatio / Box / Flex / Grid.
*/
export const skeletonMorfo = {
name: 'Skeleton',
kebab: 'skeleton',
scope: ['eidos'],
texts: {
label: '#?components.skeleton.label|Loading'
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
role: 'status',
optional: false,
data: [
{ attr: 'data-shape', value: v.propRef('shape'), severity: 'optional' },
{ attr: 'data-animated', value: v.propRef('animated'), severity: 'optional' }
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.skeleton.label|Loading'),
severity: 'recommended'
},
{ attr: 'aria-busy', value: v.literal('true') }
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,54 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Spinner — indeterminate progress indicator.
*
* Eidos-native primitive. Single `<div role="status">` with a CSS-only
* rotating glyph. Used wherever a determinate `<Progress>` is overkill
* (network request in flight, async dropdown loading, route transition).
*
* Justification for 0-event surface: like Skeleton, Spinner emits no
* commit/emerge/shift. It's pure perceptual chrome that the consumer
* unmounts when work is done.
*/
export const spinnerMorfo = {
name: 'Spinner',
kebab: 'spinner',
scope: ['eidos'],
texts: {
label: '#?components.spinner.label|Loading'
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
role: 'status',
optional: false,
data: [],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.spinner.label|Loading'),
severity: 'recommended'
},
{ attr: 'aria-live', value: v.literal('polite') }
]
},
{
// The visual rotating mark. Decorative — SR users hear the parent
// `role="status"` announcement instead.
name: 'Track',
kebab: 'track',
archetype: 'indicator',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
}
]
} as const satisfies Morfo;

@ -200,7 +200,9 @@
heading: 'Status',
items: [
{ slug: '/uix/components/progress', label: 'Progress' },
{ slug: '/uix/components/meter', label: 'Meter' }
{ slug: '/uix/components/meter', label: 'Meter' },
{ slug: '/uix/components/spinner', label: 'Spinner' },
{ slug: '/uix/components/skeleton', label: 'Skeleton' }
]
},
{

@ -0,0 +1,288 @@
<script lang="ts">
import {
Skeleton,
type SkeletonShape,
type SkeletonSize,
type SkeletonColor
} from '$uix/eidos/components/skeleton';
import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import { compileMorfo } from '$uix/morfo';
import { skeletonMorfo } from '@/uix/morfo/components/skeleton';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
let shape = $state<SkeletonShape>('rect');
let size = $state<SkeletonSize>('md');
let color = $state<SkeletonColor>('neutral');
let animated = $state(true);
let lines = $state(3);
let width = $state<string>('100%');
let height = $state<string>('40px');
const compiled = compileMorfo(skeletonMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const shapes: SkeletonShape[] = ['rect', 'circle', 'text'];
const sizes: SkeletonSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const colors: SkeletonColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Skeleton } from '$uix/eidos/components/skeleton';",
'</' + 'script>',
'',
'<Skeleton',
shape !== 'rect' && ` shape="${shape}"`,
size !== 'md' && ` size="${size}"`,
color !== 'neutral' && ` color="${color}"`,
!animated && ' animated={false}',
shape === 'text' && lines !== 1 && ` lines={${lines}}`,
shape === 'rect' && width !== '100%' && ` width="${width}"`,
shape === 'rect' && height !== '40px' && ` height="${height}"`,
'/>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Feedback · Skeleton</div>
<h1 data-uix-page-title>Skeleton</h1>
<p data-uix-page-lede>
Content placeholder shown while data loads. Three shapes
(<code>rect</code>, <code>circle</code>, <code>text</code>), five sizes, eight color accents,
and an optional shimmer animation. Pure eidos primitive — no semantic events, no soma layer.
Reduced-motion freezes the animation while keeping the visual.
</p>
<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-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>scope</span>eidos</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area>
<Box style="padding: var(--uix-space-4); display: flex; justify-content: center;">
<Box style="inline-size: min(28rem, 100%);">
{#if shape === 'rect'}
<Skeleton {shape} {size} {color} {animated} {width} {height} />
{:else if shape === 'circle'}
<Skeleton {shape} {size} {color} {animated} />
{:else}
<Skeleton {shape} {size} {color} {animated} {lines} />
{/if}
</Box>
</Box>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}
>API <span data-uix-tab-count>7</span></button
>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>0</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>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · shape and bounds
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>shape</span>
<span data-uix-chips role="radiogroup">
{#each shapes as s (s)}
<button data-uix-chip data-active={shape === s} onclick={() => (shape = s)}>{s}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>animated</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={animated} />
<span data-uix-switch-label>{animated ? 'on' : 'off'}</span>
</span>
</label>
{#if shape === 'rect'}
<label data-uix-control>
<span data-uix-control-label>width</span>
<input type="text" bind:value={width} />
</label>
<label data-uix-control>
<span data-uix-control-label>height</span>
<input type="text" bind:value={height} />
</label>
{/if}
{#if shape === 'text'}
<label data-uix-control>
<span data-uix-control-label>lines</span>
<input type="number" min="1" max="10" bind:value={lines} />
</label>
{/if}
</div>
<div data-uix-subsection-head>Composition examples</div>
<Stack gap="var(--uix-space-4)">
<div data-uix-section-desc>Avatar + 2 lines</div>
<Box
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);"
>
<Skeleton shape="circle" size="lg" />
<Box style="flex: 1;">
<Skeleton shape="text" lines={2} />
</Box>
</Box>
<div data-uix-section-desc>Card with image + content</div>
<Box
style="border: 1px solid var(--uix-border); border-radius: var(--uix-radius-md); overflow: hidden;"
>
<Skeleton width="100%" height="160px" />
<Box style="padding: var(--uix-space-4);">
<Skeleton shape="text" lines={3} />
</Box>
</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>
{:else if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> contract
</h2>
<p data-uix-section-desc>
One provider part with <code>role="status"</code> and <code>aria-busy="true"</code>. Carries
<code>data-shape</code> + <code>data-animated</code> attrs that the eidos recipe consumes.
</p>
<table data-uix-api-table>
<thead><tr><th>part</th><th>element</th><th>role</th><th>archetype</th></tr></thead>
<tbody>
{#each partsList as p (p.kebab)}
<tr>
<td><code>{p.kebab}</code></td>
<td><code>{p.defaultElement}</code></td>
<td>{p.role ?? '—'}</td>
<td>{p.archetype ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</section>
{:else if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> events
</h2>
<p data-uix-section-desc>
Skeleton has no semantic events — it's a pure visual placeholder that the consumer unmounts
when real content is ready. The morfo declares no events, so the perceptual cascade has
nothing to react to. The single Provider part carries <code>aria-busy</code> +
<code>role="status"</code> for assistive tech.
</p>
</section>
{:else if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Recipe</h2>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> · canonical usage
</div>
<pre data-uix-snippet><code>{eidosSnippet}</code></pre>
</section>
{:else if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<ul>
<li>
Renders <code>&lt;div role="status" aria-busy="true"&gt;</code>. Screen readers will
announce the configured label ("Loading" in EN, "Cargando" in ES) when the skeleton
enters the DOM.
</li>
<li>
When swapped with real content, the screen reader hears the new content because the
Skeleton element is removed entirely (not just hidden).
</li>
<li>
<code>aria-label</code> can override the default "Loading" string with a more specific
message (e.g. <code>aria-label="Loading user profile"</code>).
</li>
<li>
Animation respects <code>prefers-reduced-motion</code> — under reduced motion the shimmer
stops but the placeholder shape remains visible.
</li>
<li>
Color contrast: the placeholder uses <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.
</li>
</ul>
</section>
{/if}
</div>

@ -0,0 +1,261 @@
<script lang="ts">
import {
Spinner,
type SpinnerSize,
type SpinnerColor,
type SpinnerVariant
} from '$uix/eidos/components/spinner';
import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import { compileMorfo } from '$uix/morfo';
import { spinnerMorfo } from '@/uix/morfo/components/spinner';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
let variant = $state<SpinnerVariant>('ring');
let size = $state<SpinnerSize>('md');
let color = $state<SpinnerColor>('primary');
let withLabel = $state(false);
const compiled = compileMorfo(spinnerMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const variants: SpinnerVariant[] = ['ring', 'dots', 'bars'];
const sizes: SpinnerSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const colors: SpinnerColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Spinner } from '$uix/eidos/components/spinner';",
'</' + 'script>',
'',
'<Spinner',
variant !== 'ring' && ` variant="${variant}"`,
size !== 'md' && ` size="${size}"`,
color !== 'primary' && ` color="${color}"`,
'>',
withLabel && ' Loading…',
'</Spinner>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Feedback · Spinner</div>
<h1 data-uix-page-title>Spinner</h1>
<p data-uix-page-lede>
Indeterminate progress indicator. Three visual variants (<code>ring</code>,
<code>dots</code>, <code>bars</code>), five sizes, eight color accents, and an optional
inline label. Pure eidos primitive — no semantic events. Use
<code>&lt;Progress&gt;</code> instead when you have a known percentage.
</p>
<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-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>scope</span>eidos</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area>
<Box
style="padding: var(--uix-space-6); display: flex; align-items: center; justify-content: center;"
>
{#if withLabel}
<Spinner {variant} {size} {color}>Loading…</Spinner>
{:else}
<Spinner {variant} {size} {color} />
{/if}
</Box>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}
>API <span data-uix-tab-count>5</span></button
>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>0</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>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}
>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}>{c}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>with label</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={withLabel} />
<span data-uix-switch-label>{withLabel ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>All variants side-by-side</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>
<div data-uix-subsection-head>Color matrix</div>
<Box style="display: flex; gap: var(--uix-space-5); flex-wrap: wrap; padding: var(--uix-space-3);">
{#each colors as c (c)}
<Box style="display: flex; flex-direction: column; align-items: center; gap: var(--uix-space-2);">
<Spinner {variant} {size} color={c} />
<code>{c}</code>
</Box>
{/each}
</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>
{:else if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> contract
</h2>
<p data-uix-section-desc>
Two parts: <code>provider</code> (role=status, polite live region) and <code>track</code>
(decorative, aria-hidden). The variant prop only swaps the inner DOM of Track.
</p>
<table data-uix-api-table>
<thead><tr><th>part</th><th>element</th><th>role</th><th>archetype</th></tr></thead>
<tbody>
{#each partsList as p (p.kebab)}
<tr>
<td><code>{p.kebab}</code></td>
<td><code>{p.defaultElement}</code></td>
<td>{p.role ?? '—'}</td>
<td>{p.archetype ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</section>
{:else if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> events
</h2>
<p data-uix-section-desc>
Spinner has no semantic events — the loading state is announced only through ARIA. There's
no commit/emerge/shift to react to. Mount when work starts; unmount when it ends.
</p>
</section>
{:else if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Recipe</h2>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> · canonical usage
</div>
<pre data-uix-snippet><code>{eidosSnippet}</code></pre>
</section>
{:else if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<ul>
<li>
<code>role="status"</code> + <code>aria-live="polite"</code> on the root — the screen
reader announces the configured label when the spinner enters the DOM (typically when
you start a fetch).
</li>
<li>
Default label is the translated "Loading" / "Cargando". Override per-instance with
<code>aria-label="Loading orders"</code> for more specific context.
</li>
<li>
Visual track parts (<code>data-spinner-track</code>, <code>-dot</code>, <code>-bar</code>)
are <code>aria-hidden="true"</code> — decoration only.
</li>
<li>
Animation respects <code>prefers-reduced-motion</code> — under reduced motion the
rotation/bounce/stretch keyframes stop, but the visual stays so the user still sees the
loading affordance.
</li>
<li>
Use <code>&lt;Spinner&gt;</code> for unknown progress (network requests, async
rendering). Switch to <code>&lt;Progress&gt;</code> when you can report a percentage.
</li>
</ul>
</section>
{/if}
</div>

@ -1375,3 +1375,120 @@
font-size: 11px;
font-weight: var(--uix-weight-medium);
}
/* ── 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.