From 616d8535f71b9e4edcec97c0c6d46baf641e24c2 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 24 May 2026 12:07:18 +0200 Subject: [PATCH] feat(eidos): add Skeleton and Spinner loader primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `
` 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) --- src/uix/eidos/components/skeleton/index.ts | 12 + src/uix/eidos/components/skeleton/langs.ts | 4 + .../eidos/components/skeleton/skeleton.css | 144 +++++++++ .../eidos/components/skeleton/skeleton.svelte | 81 +++++ src/uix/eidos/components/skeleton/types.ts | 35 +++ src/uix/eidos/components/spinner/index.ts | 12 + src/uix/eidos/components/spinner/langs.ts | 4 + src/uix/eidos/components/spinner/spinner.css | 194 ++++++++++++ .../eidos/components/spinner/spinner.svelte | 64 ++++ src/uix/eidos/components/spinner/types.ts | 28 ++ src/uix/langs/components/index.ts | 4 + src/uix/langs/components/skeleton.ts | 12 + src/uix/langs/components/spinner.ts | 12 + src/uix/morfo/components/skeleton.ts | 47 +++ src/uix/morfo/components/spinner.ts | 54 ++++ web/routes/uix/+layout@.svelte | 4 +- .../uix/components/skeleton/+page.svelte | 288 ++++++++++++++++++ .../uix/components/spinner/+page.svelte | 261 ++++++++++++++++ web/routes/uix/uix.css | 117 +++++++ 19 files changed, 1376 insertions(+), 1 deletion(-) create mode 100644 src/uix/eidos/components/skeleton/index.ts create mode 100644 src/uix/eidos/components/skeleton/langs.ts create mode 100644 src/uix/eidos/components/skeleton/skeleton.css create mode 100644 src/uix/eidos/components/skeleton/skeleton.svelte create mode 100644 src/uix/eidos/components/skeleton/types.ts create mode 100644 src/uix/eidos/components/spinner/index.ts create mode 100644 src/uix/eidos/components/spinner/langs.ts create mode 100644 src/uix/eidos/components/spinner/spinner.css create mode 100644 src/uix/eidos/components/spinner/spinner.svelte create mode 100644 src/uix/eidos/components/spinner/types.ts create mode 100644 src/uix/langs/components/skeleton.ts create mode 100644 src/uix/langs/components/spinner.ts create mode 100644 src/uix/morfo/components/skeleton.ts create mode 100644 src/uix/morfo/components/spinner.ts create mode 100644 web/routes/uix/components/skeleton/+page.svelte create mode 100644 web/routes/uix/components/spinner/+page.svelte diff --git a/src/uix/eidos/components/skeleton/index.ts b/src/uix/eidos/components/skeleton/index.ts new file mode 100644 index 000000000..416c7cfa6 --- /dev/null +++ b/src/uix/eidos/components/skeleton/index.ts @@ -0,0 +1,12 @@ +// Skeleton — content placeholder primitive. +// +// import { Skeleton } from '$uix/eidos/components/skeleton'; +// +// +// +// +import Skeleton from './skeleton.svelte'; + +export { Skeleton }; +export default Skeleton; +export type { SkeletonProps, SkeletonShape, SkeletonSize, SkeletonColor } from './types'; diff --git a/src/uix/eidos/components/skeleton/langs.ts b/src/uix/eidos/components/skeleton/langs.ts new file mode 100644 index 000000000..059434f5f --- /dev/null +++ b/src/uix/eidos/components/skeleton/langs.ts @@ -0,0 +1,4 @@ +/** Idlangref constants for the Skeleton component. */ +export const SKELETON_LANGS = { + LABEL: '#?components.skeleton.label|Loading' +} as const; diff --git a/src/uix/eidos/components/skeleton/skeleton.css b/src/uix/eidos/components/skeleton/skeleton.css new file mode 100644 index 000000000..67b5ead07 --- /dev/null +++ b/src/uix/eidos/components/skeleton/skeleton.css @@ -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; + } +} diff --git a/src/uix/eidos/components/skeleton/skeleton.svelte b/src/uix/eidos/components/skeleton/skeleton.svelte new file mode 100644 index 000000000..2d15dd5cc --- /dev/null +++ b/src/uix/eidos/components/skeleton/skeleton.svelte @@ -0,0 +1,81 @@ + + +{#if resolvedShape === 'text' && lineWidths} +
+ {#each lineWidths as w, i (i)} + + {/each} +
+{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/eidos/components/skeleton/types.ts b/src/uix/eidos/components/skeleton/types.ts new file mode 100644 index 000000000..dd17735af --- /dev/null +++ b/src/uix/eidos/components/skeleton/types.ts @@ -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; +export type SkeletonColor = ColorRole; + +/** + * Props for ``. + * + * Composes through `` 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 & { + /** Visual shape. @default 'rect' */ + shape?: ResponsiveProp; + /** Reservation size. @default 'md' (only meaningful when no width/height set) */ + size?: ResponsiveProp; + /** 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; +}; diff --git a/src/uix/eidos/components/spinner/index.ts b/src/uix/eidos/components/spinner/index.ts new file mode 100644 index 000000000..cffcdb7e4 --- /dev/null +++ b/src/uix/eidos/components/spinner/index.ts @@ -0,0 +1,12 @@ +// Spinner — indeterminate progress indicator. +// +// import { Spinner } from '$uix/eidos/components/spinner'; +// +// +// +// Loading… +import Spinner from './spinner.svelte'; + +export { Spinner }; +export default Spinner; +export type { SpinnerProps, SpinnerSize, SpinnerColor, SpinnerVariant } from './types'; diff --git a/src/uix/eidos/components/spinner/langs.ts b/src/uix/eidos/components/spinner/langs.ts new file mode 100644 index 000000000..2d639c0ad --- /dev/null +++ b/src/uix/eidos/components/spinner/langs.ts @@ -0,0 +1,4 @@ +/** Idlangref constants for the Spinner component. */ +export const SPINNER_LANGS = { + LABEL: '#?components.spinner.label|Loading' +} as const; diff --git a/src/uix/eidos/components/spinner/spinner.css b/src/uix/eidos/components/spinner/spinner.css new file mode 100644 index 000000000..82da6e2c1 --- /dev/null +++ b/src/uix/eidos/components/spinner/spinner.css @@ -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; + } +} diff --git a/src/uix/eidos/components/spinner/spinner.svelte b/src/uix/eidos/components/spinner/spinner.svelte new file mode 100644 index 000000000..f6ba54d06 --- /dev/null +++ b/src/uix/eidos/components/spinner/spinner.svelte @@ -0,0 +1,64 @@ + + +
+ {#if variant === 'dots'} + + {:else if variant === 'bars'} + + {:else} + + {/if} + {#if children} + {@render children()} + {/if} +
diff --git a/src/uix/eidos/components/spinner/types.ts b/src/uix/eidos/components/spinner/types.ts new file mode 100644 index 000000000..5b55af4f2 --- /dev/null +++ b/src/uix/eidos/components/spinner/types.ts @@ -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; +export type SpinnerColor = ColorRole; +export type SpinnerVariant = 'ring' | 'dots' | 'bars'; + +/** + * Props for ``. + * + * Composes through `` 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 & { + /** Visual style. @default 'ring' */ + variant?: SpinnerVariant; + /** Spinner size. @default 'md' */ + size?: ResponsiveProp; + /** 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; +}; diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index 8e9676bb7..97ef74c2f 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -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, diff --git a/src/uix/langs/components/skeleton.ts b/src/uix/langs/components/skeleton.ts new file mode 100644 index 000000000..1287d1346 --- /dev/null +++ b/src/uix/langs/components/skeleton.ts @@ -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; diff --git a/src/uix/langs/components/spinner.ts b/src/uix/langs/components/spinner.ts new file mode 100644 index 000000000..bb76b5ef2 --- /dev/null +++ b/src/uix/langs/components/spinner.ts @@ -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; diff --git a/src/uix/morfo/components/skeleton.ts b/src/uix/morfo/components/skeleton.ts new file mode 100644 index 000000000..f7e4ba7a8 --- /dev/null +++ b/src/uix/morfo/components/skeleton.ts @@ -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 `
` 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; diff --git a/src/uix/morfo/components/spinner.ts b/src/uix/morfo/components/spinner.ts new file mode 100644 index 000000000..c616399cd --- /dev/null +++ b/src/uix/morfo/components/spinner.ts @@ -0,0 +1,54 @@ +import type { Morfo } from '../types'; +import { v } from '../types'; + +/** + * Spinner — indeterminate progress indicator. + * + * Eidos-native primitive. Single `
` with a CSS-only + * rotating glyph. Used wherever a determinate `` 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; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 952ad07c7..5beba3a1a 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -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' } ] }, { diff --git a/web/routes/uix/components/skeleton/+page.svelte b/web/routes/uix/components/skeleton/+page.svelte new file mode 100644 index 000000000..32dd78970 --- /dev/null +++ b/web/routes/uix/components/skeleton/+page.svelte @@ -0,0 +1,288 @@ + + +
+
+
Feedback · Skeleton
+

Skeleton

+

+ Content placeholder shown while data loads. Three shapes + (rect, circle, text), 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. +

+
+ parts{partsList.length} + events0 + shapes{shapes.length} + scopeeidos +
+
+ +
+
+ + + {#if shape === 'rect'} + + {:else if shape === 'circle'} + + {:else} + + {/if} + + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+ +
+ eidos props · shape and bounds +
+
+ + + + + {#if shape === 'rect'} + + + {/if} + {#if shape === 'text'} + + {/if} +
+ +
Composition examples
+ +
Avatar + 2 lines
+ + + + + + + +
Card with image + content
+ + + + + + +
+
+ {:else if tab === 'api'} +
+

API

+ + + + + + + + + + + + + + + +
proptypedefaultdescription
shape'rect' | 'circle' | 'text''rect'Visual shape.
size'xs'..'xl''md'Intrinsic block-size.
colorColorRole'neutral'Placeholder tint.
linesnumber1Bar count when shape='text'.
animatedbooleantrueShimmer wave (CSS keyframes).
aria-labelstring?—Override the translated 'Loading'.
...BoxBoxProps—width/height/padding/gridColumn/…
+
+ {:else if tab === 'morfo'} +
+

+ morfo contract +

+

+ One provider part with role="status" and aria-busy="true". Carries + data-shape + data-animated attrs that the eidos recipe consumes. +

+ + + + {#each partsList as p (p.kebab)} + + + + + + + {/each} + +
partelementrolearchetype
{p.kebab}{p.defaultElement}{p.role ?? '—'}{p.archetype ?? '—'}
+
+ {:else if tab === 'sema'} +
+

+ sema events +

+

+ 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 aria-busy + + role="status" for assistive tech. +

+
+ {:else if tab === 'recipe'} +
+

Recipe

+
+ eidos · canonical usage +
+
{eidosSnippet}
+
+ {:else if tab === 'a11y'} +
+

Accessibility

+
    +
  • + Renders <div role="status" aria-busy="true">. Screen readers will + announce the configured label ("Loading" in EN, "Cargando" in ES) when the skeleton + enters the DOM. +
  • +
  • + When swapped with real content, the screen reader hears the new content because the + Skeleton element is removed entirely (not just hidden). +
  • +
  • + aria-label can override the default "Loading" string with a more specific + message (e.g. aria-label="Loading user profile"). +
  • +
  • + Animation respects prefers-reduced-motion — under reduced motion the shimmer + stops but the placeholder shape remains visible. +
  • +
  • + Color contrast: the placeholder uses color-mix at 50–60% opacity over the + surface, low enough to read as "non-content" but high enough to register against most + backgrounds. +
  • +
+
+ {/if} +
diff --git a/web/routes/uix/components/spinner/+page.svelte b/web/routes/uix/components/spinner/+page.svelte new file mode 100644 index 000000000..fd9d91314 --- /dev/null +++ b/web/routes/uix/components/spinner/+page.svelte @@ -0,0 +1,261 @@ + + +
+
+
Feedback · Spinner
+

Spinner

+

+ Indeterminate progress indicator. Three visual variants (ring, + dots, bars), five sizes, eight color accents, and an optional + inline label. Pure eidos primitive — no semantic events. Use + <Progress> instead when you have a known percentage. +

+
+ parts{partsList.length} + events0 + variants{variants.length} + scopeeidos +
+
+ +
+
+ + {#if withLabel} + Loading… + {:else} + + {/if} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+ +
+ eidos props +
+
+ + + + +
+ +
All variants side-by-side
+ + {#each variants as v (v)} + + {v} + {#each sizes as s (s)} + + {/each} + + {/each} + + +
Color matrix
+ + {#each colors as c (c)} + + + {c} + + {/each} + +
+ {:else if tab === 'api'} +
+

API

+ + + + + + + + + + + +
proptypedefaultdescription
variant'ring' | 'dots' | 'bars''ring'Visual style.
size'xs'..'xl''md'Track diameter / dot size.
colorColorRole'primary'Accent palette.
aria-labelstring?—Override the translated 'Loading'.
childrenSnippet?—Optional inline label.
+
+ {:else if tab === 'morfo'} +
+

+ morfo contract +

+

+ Two parts: provider (role=status, polite live region) and track + (decorative, aria-hidden). The variant prop only swaps the inner DOM of Track. +

+ + + + {#each partsList as p (p.kebab)} + + + + + + + {/each} + +
partelementrolearchetype
{p.kebab}{p.defaultElement}{p.role ?? '—'}{p.archetype ?? '—'}
+
+ {:else if tab === 'sema'} +
+

+ sema events +

+

+ 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. +

+
+ {:else if tab === 'recipe'} +
+

Recipe

+
+ eidos · canonical usage +
+
{eidosSnippet}
+
+ {:else if tab === 'a11y'} +
+

Accessibility

+
    +
  • + role="status" + aria-live="polite" on the root — the screen + reader announces the configured label when the spinner enters the DOM (typically when + you start a fetch). +
  • +
  • + Default label is the translated "Loading" / "Cargando". Override per-instance with + aria-label="Loading orders" for more specific context. +
  • +
  • + Visual track parts (data-spinner-track, -dot, -bar) + are aria-hidden="true" — decoration only. +
  • +
  • + Animation respects prefers-reduced-motion — under reduced motion the + rotation/bounce/stretch keyframes stop, but the visual stays so the user still sees the + loading affordance. +
  • +
  • + Use <Spinner> for unknown progress (network requests, async + rendering). Switch to <Progress> when you can report a percentage. +
  • +
+
+ {/if} +
diff --git a/web/routes/uix/uix.css b/web/routes/uix/uix.css index 9f51f9c86..6ddfa0490 100644 --- a/web/routes/uix/uix.css +++ b/web/routes/uix/uix.css @@ -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); + } +}