uix(empty-state): F1.2 · componente base display (morfo+eidos) al suelo del dossier

Primera pieza F1 del plan blocks (PLAN-blocks.md; alcance E-2 = suelo de
paridad, dossier §P3). Ruta de 9 fases completa:

- morfo: 5 partes display (provider/media/title/description/actions),
  scope ['eidos'], 0 eventos con justificacion pasiva (patron
  renderEmptyState de las refs headless), Title role:'heading', texts.label
- langs: components.empty-state.label (es/en) — el indice tambien recoge la
  retirada foranea del import de words (inseparable por staging de archivo;
  coherente con la migracion palabras ya enviada; words.ts sigue en su arbol)
- eidos: compound EmptyState + Media(kind icon|media, placa 2x glifo) +
  Title(level 2-6, default h3 — modelo Atlaskit, tamano visual desacoplado) +
  Description(measure 45ch) + Actions(label -> role=group +aria-label,
  buttonGroupLabel); recipe sobre el bundle --size-* (titulo un paso
  discreto arriba; sm=in-collection, lg=hero); tokens publicos minimos
  (gap/actions-gap/media-bg/media-fg/media-radius/description-measure)
- demo v2 9 tabs (harness: SystemAxes/MotionPanel/SemaPanel; snippet con
  paridad; escena in-collection via Card) + entrada nav (grupo Status)
- README: Baseline · Comparativa (shadcn/Chakra/Atlaskit/AntD/Polaris) ·
  Decisiones · Passive justification · Gaps con disposicion

Verificacion: component:audit PASS · eidos-lint 5 morfo-backed + 4
eidos-only sancionados · morfo:check + morfo:vocabulary verdes ·
recipe-css-contract/api-contract/visual-attrs 37/37 · svelte-check
76E/51W = baseline exacto (cero regresion) · navegador claro Y oscuro por
estilos computados (titulo 18->24px, placa 40->64px, chips vivos, cero
errores de consola).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent cd30524d99
commit f19e91b985

@ -31,7 +31,7 @@
| Fase | Contenido | Estado |
|---|---|---|
| **F0** | Infraestructura del tier: doctrina + alias + guard + rutas demo | **HECHA 2026-07-21** (F0.1–F0.7; cross-ref en `comparison.md` omitido a propósito — sin aporte hasta que exista catálogo) |
| **F1** | **8** componentes base del CANON que los blocks necesitan (7 + `nav-tree` por E-1) | pendiente |
| **F1** | **8** componentes base del CANON que los blocks necesitan (7 + `nav-tree` por E-1) | en curso — **F1.2 `empty-state` HECHA 2026-07-21** (PASS; ver registro) |
| **F2** | Blocks de sitio (**14**: 10 + banner·team·contact·content-section por E-3) | pendiente (F2 solo requiere F1.1) |
| **F3** | Blocks de aplicación (10) | pendiente |
| **F4** | Blocks de docs (3) | pendiente |
@ -829,3 +829,16 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
propia en `next-features.md` §9; **E-5** ⌘K = listener app-land (nota en
F4.1). Doctrina (`architecture/blocks.md` promotion path) y galería
actualizadas.
- 2026-07-21 — **F1.2 `empty-state` HECHA** (ruta de 9 fases completa,
suelo E-2 del dossier §P3): morfo display 5 partes (`scope: ['eidos']`,
0 eventos justificados, Title `role:'heading'`) + langs `label` + eidos
compound (`Media kind=icon|media` con placa 2× glifo · `Title level`
2–6 default h3, modelo Atlaskit · `Description` measure 45ch ·
`Actions label` → role=group, buttonGroupLabel) + recipe sobre el bundle
`--size-*` (título un paso discreto arriba) + demo v2 de 9 tabs + README
(Comparativa 5 refs · Passive justification · Gaps con disposición).
Verificado: `component:audit` **PASS** · eidos-lint 5 morfo-backed + 4
eidos-only sancionados · `svelte-check` 76E/51W = baseline exacto ·
navegador claro Y oscuro por estilos computados (título 18→24px, placa
40→64px, chips vivos). Siguiente: **F1.3 `result`** (comparte esqueleto;
fase 0 contra dossier §P3).

@ -0,0 +1,84 @@
# EmptyState
Passive "nothing here yet / no results" display surface: media (icon plate
or free illustration) + title + description + actions, centered. Built
2026-07-21 as F1.2 of the blocks initiative (`docs/process/PLAN-blocks.md`);
reference floor fixed by the research dossier
(`docs/process/RESEARCH-blocks-references.md` §P3) under the E-2 rule
(parity floor = v1).
## Baseline
- **Classification**: passive display (eidos-native, `scope: ['eidos']` —
same membership as `banner`/`box`). No soma provider, no sema pack.
- **Anatomy**: `EmptyState` (root, `data-size` sm|md|lg) → `.Media`
(`data-kind` icon|media) + `.Title` (real `<h{level}>`, default h3) +
`.Description` (`<p>`, measure-capped) + `.Actions` (consumer `Button`s,
optional `label` → `role="group"` + `aria-label`).
- **0 events, justified**: appearing/disappearing is a fact of the
collection that renders the empty (table/list/select/command), not of
this surface — announcing belongs to the collection's live region.
Headless references model empties the same way (React Aria
`renderEmptyState`, HeroUI `emptyContent`). The composed Buttons own
their own sema.
- **Sizing**: `sm` = compact in-collection density · `md` = panel default
· `lg` = full-page hero. Typography rides the `--size-{k}-*` bundle;
the title reads ONE discrete bundle size above the component size
(visual hierarchy decoupled from the semantic `level`).
## Comparativa
| Ref | Anatomy | What we adopted | What we didn't |
| ---------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| shadcn/ui `Empty` | Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription/EmptyContent | `Media variant="icon"` → our `kind`; part naming | The `EmptyHeader` grouping wrapper (root centers directly; one less part) |
| Chakra v3 `EmptyState` | Root/Content/Indicator/Title/Description | `size` sm/md/lg axis | The `Content` wrapper (same reason) |
| Atlaskit `EmptyState` | props-only (header/description/actions) | `headingLevel` model (our `level`, default h3 vs their h4) + `buttonGroupLabel` (our `Actions label`) + image clamp doctrine | `width narrow\|wide` (our measure token covers it); `isLoading` spinner-beside-actions (defer until a real consumer) |
| AntD `Empty` | image/description/footer | The compact in-collection concept (their SIMPLE preset ≈ our `sm`) | `ConfigProvider.renderEmpty` global injection (tracked gap below) |
| Polaris `EmptyState` | heading/image/action/secondaryAction/footerContent | Content doctrine: ONE primary action, verb-first; reason + next step | Required image (ours optional); `fullWidth/imageContained` |
## Decisiones
- **Title is a real heading** (Atlaskit model), default `h3`, levels 2–6.
Rationale: empties often ARE the section content and should sit in the
document outline; the visual size is decoupled (rides `size`), so
choosing the right level costs nothing visually. (Callout will decide
the opposite — its title is NOT a heading — because callouts are inline
asides; the difference is deliberate.)
- **No `color`/intent prop**: a neutral display surface. Error-flavored
empties ("no results" after a failed search) are still neutral surfaces
with recovery actions — terminal flow states with evaluative status
belong to `Result` (F1.3), which shares this skeleton and adds
`data-status`.
- **Media plate = 2× glyph** (`/* literal: */` in the recipe): keeps the
tinted plate readable at every size without a parallel size table.
- **`sm` exists for collections**: Table/Select/Command empties compose
`<EmptyState size="sm">` inside their own empty slot.
- **Public tokens intentionally small**: gap · actions-gap · media-bg ·
media-fg · media-radius · description-measure (45ch). Density stays
internal on `data-size`.
## Passive justification
EmptyState declares 0 events because it is a passive display surface:
appearing/disappearing is a fact of the COLLECTION that renders it
(table/list/select/command), not of this surface — announcing it belongs to
the collection's live region, mirroring how headless references model
empties as a render slot of collections (React Aria `renderEmptyState`
stamping `[data-empty]`, HeroUI `emptyContent`). The composed `Button`s in
`.Actions` are the interactive elements and own their own behavior and
sema. No keyboard contract, no state machine, no ARIA obligations beyond
the optional named actions group — same passive class as `banner`/`box`.
## Sema events
None (see Passive justification). `SemaPanel` in the demo renders the
justified empty state.
## Gaps
| Gap | Disposition |
| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Collection integration (AntD `renderEmpty`-style: Table/Select/Command render an EmptyState automatically when dataless) | **diferir** — belongs to each collection's empty slot, not to this component; wire when the data-table block (F3.3) lands and fixes the pattern |
| `isLoading` action spinner (Atlaskit) | **diferir** until a real consumer asks; Buttons already have their own loading treatment |
| Illustration presets (AntD ships two images) | **descartar** — media is a free slot by design; apps bring their art (no baked visuals) |
| Global default media per app (theme-level) | **diferir** — candidate for a `recipes.empty-state.*` extension or app-level wrapper; no consumer yet |

@ -0,0 +1,22 @@
<script lang="ts">
/**
* Eidos `<EmptyState.Actions>` — the action row. Hosts consumer
* `Button`s (one primary — Polaris doctrine — plus optional secondary /
* tertiary link). `label` names the group accessibly (Atlaskit
* `buttonGroupLabel`): when set, the part renders `role="group"` +
* `aria-label`.
*/
import type { EmptyStateActionsProps } from './types';
let { label, class: className, children, ...restProps }: EmptyStateActionsProps = $props();
</script>
<div
{...restProps}
class={className}
role={label ? 'group' : undefined}
aria-label={label}
data-empty-state-actions=""
>
{@render children?.()}
</div>

@ -0,0 +1,13 @@
<script lang="ts">
/**
* Eidos `<EmptyState.Description>` — muted secondary text under the
* title, capped to a readable measure by the recipe.
*/
import type { EmptyStateDescriptionProps } from './types';
let { class: className, children, ...restProps }: EmptyStateDescriptionProps = $props();
</script>
<p {...restProps} class={className} data-empty-state-description="">
{@render children?.()}
</p>

@ -0,0 +1,16 @@
<script lang="ts">
/**
* Eidos `<EmptyState.Media>` — the media slot. `kind="icon"` (default)
* paints the tinted icon box; `kind="media"` is a free slot for
* illustrations / images / avatar groups with no box chrome. Decorative
* either way — pass `aria-hidden` content or meaningful `alt`s on the
* media itself.
*/
import type { EmptyStateMediaProps } from './types';
let { kind = 'icon', class: className, children, ...restProps }: EmptyStateMediaProps = $props();
</script>
<div {...restProps} class={className} data-empty-state-media="" data-kind={kind}>
{@render children?.()}
</div>

@ -0,0 +1,14 @@
<script lang="ts">
/**
* Eidos `<EmptyState.Title>` — real heading with configurable semantic
* level (Atlaskit `headingLevel` model, default `<h3>`); the VISUAL
* size rides the component `size` axis, decoupled from the level.
*/
import type { EmptyStateTitleProps } from './types';
let { level = 3, class: className, children, ...restProps }: EmptyStateTitleProps = $props();
</script>
<svelte:element this={`h${level}`} {...restProps} class={className} data-empty-state-title="">
{@render children?.()}
</svelte:element>

@ -0,0 +1,103 @@
/*
* EmptyState recipe — passive "nothing here / no results" surface.
* Centered column: media (icon box or free media) + title + description +
* actions. `data-size` (sm | md | lg) drives density: `sm` is the compact
* in-collection empty (table/select/command), `md` the panel default,
* `lg` the full-page hero.
*
* `--empty-state-*` tokens are public overrides (recipes.empty-state.*);
* `--_empty-state-*` are internal recipe-local resolutions. Typography
* rides the `--size-{k}-*` bundle; the title steps ONE discrete size up
* from the component size (visual hierarchy without a heading-level
* coupling — level is semantic, size is visual).
*/
[data-empty-state] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--empty-state-gap, var(--space-3));
padding-block: var(--_empty-state-padding-block);
padding-inline: var(--_empty-state-padding-inline);
min-inline-size: 0;
box-sizing: border-box;
}
/* ── Size ──────────────────────────────────────────────────────────── */
[data-empty-state][data-size='sm'] {
--_empty-state-padding-block: var(--space-4);
--_empty-state-padding-inline: var(--space-3);
--_empty-state-title-font-size: var(--size-md-font-size);
--_empty-state-body-font-size: var(--size-sm-font-size);
--_empty-state-media-glyph: var(--size-sm-icon-size);
}
[data-empty-state][data-size='md'] {
--_empty-state-padding-block: var(--space-8);
--_empty-state-padding-inline: var(--space-4);
--_empty-state-title-font-size: var(--size-lg-font-size);
--_empty-state-body-font-size: var(--size-md-font-size);
--_empty-state-media-glyph: var(--size-lg-icon-size);
}
[data-empty-state][data-size='lg'] {
--_empty-state-padding-block: var(--space-12);
--_empty-state-padding-inline: var(--space-5);
--_empty-state-title-font-size: var(--size-xl-font-size);
--_empty-state-body-font-size: var(--size-md-font-size);
--_empty-state-media-glyph: var(--size-xl-icon-size);
}
/* ── Media ─────────────────────────────────────────────────────────── */
[data-empty-state-media] {
display: flex;
align-items: center;
justify-content: center;
color: var(--empty-state-media-fg, var(--color-content-muted));
}
[data-empty-state-media][data-kind='icon'] {
/* literal: the icon box is proportional to its glyph (2× keeps the
tinted plate readable at every size) */
inline-size: calc(var(--_empty-state-media-glyph) * 2);
block-size: calc(var(--_empty-state-media-glyph) * 2);
font-size: var(--_empty-state-media-glyph);
background: var(--empty-state-media-bg, var(--color-neutral-track));
border-radius: var(--empty-state-media-radius, var(--radius-lg));
}
/* ── Title ─────────────────────────────────────────────────────────── */
[data-empty-state-title] {
margin: 0;
font-size: var(--_empty-state-title-font-size);
font-weight: var(--font-weight-semibold);
line-height: var(--font-line-height-sm, 1.4);
color: var(--color-content-primary);
}
/* ── Description ───────────────────────────────────────────────────── */
[data-empty-state-description] {
margin: 0;
font-size: var(--_empty-state-body-font-size);
line-height: var(--font-line-height-md, 1.5);
color: var(--color-content-muted);
max-inline-size: var(--empty-state-description-measure, 45ch);
text-wrap: pretty;
}
/* ── Actions ───────────────────────────────────────────────────────── */
[data-empty-state-actions] {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--empty-state-actions-gap, var(--space-2));
margin-block-start: var(--space-1);
}

@ -0,0 +1,31 @@
<script lang="ts">
import './empty-state.css';
/**
* Eidos `<EmptyState>` — passive "nothing here yet / no results"
* surface: media + title + description + actions, centered. Display
* only (eidos-native, no soma): the composed `Button`s in `.Actions`
* carry their own behavior; announcing an empty's appearance belongs
* to the collection that renders it.
*
* <EmptyState size="md">
* <EmptyState.Media><Icon name="inbox" /></EmptyState.Media>
* <EmptyState.Title>No projects yet</EmptyState.Title>
* <EmptyState.Description>Create your first project to get started.</EmptyState.Description>
* <EmptyState.Actions label="Empty state actions">
* <Button>Create project</Button>
* </EmptyState.Actions>
* </EmptyState>
*/
import { ActiveEidos } from '$uix/eidos';
import type { EmptyStateProps } from './types';
let { size = 'md', class: className, children, ...restProps }: EmptyStateProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size) ?? 'md');
</script>
<div {...restProps} class={className} data-empty-state="" data-size={resolvedSize}>
{@render children?.()}
</div>

@ -0,0 +1,43 @@
// EmptyState — passive "nothing here yet / no results" display surface.
//
// import { EmptyState } from '$uix/eidos/components/empty-state';
//
// <EmptyState size="md">
// <EmptyState.Media><Icon name="inbox" /></EmptyState.Media>
// <EmptyState.Title>No projects yet</EmptyState.Title>
// <EmptyState.Description>Create your first project to get started.</EmptyState.Description>
// <EmptyState.Actions label="Empty state actions">
// <Button>Create project</Button>
// </EmptyState.Actions>
// </EmptyState>
import EmptyStateComponent from './empty-state.svelte';
import Media from './empty-state-media.svelte';
import Title from './empty-state-title.svelte';
import Description from './empty-state-description.svelte';
import Actions from './empty-state-actions.svelte';
type EmptyStateNamespace = typeof EmptyStateComponent & {
Media: typeof Media;
Title: typeof Title;
Description: typeof Description;
Actions: typeof Actions;
};
const EmptyState = EmptyStateComponent as EmptyStateNamespace;
EmptyState.Media = Media;
EmptyState.Title = Title;
EmptyState.Description = Description;
EmptyState.Actions = Actions;
export { EmptyState };
export default EmptyState;
export type {
EmptyStateProps,
EmptyStateMediaProps,
EmptyStateTitleProps,
EmptyStateDescriptionProps,
EmptyStateActionsProps,
EmptyStateSize,
EmptyStateMediaKind,
EmptyStateTitleLevel
} from './types';

@ -0,0 +1,58 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
/**
* Size scale exposed by EmptyState. Narrowed from the canonical `Size`
* scale to the values the recipe maps: `sm` is the compact in-collection
* density (table/select/command empties), `md` the default panel size,
* `lg` the full-page hero size.
*/
export type EmptyStateSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/**
* Media slot treatment. `icon` renders the tinted icon box (the shadcn
* `EmptyMedia variant="icon"` pattern); `media` is a free slot for
* illustrations / avatar groups / images with no box chrome.
*/
export type EmptyStateMediaKind = 'icon' | 'media';
/** Semantic heading levels the Title part may render (Atlaskit model). */
export type EmptyStateTitleLevel = 2 | 3 | 4 | 5 | 6;
export type EmptyStateProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/**
* Density scale — `sm` for in-collection empties, `lg` for full-page.
* @default 'md'
*/
size?: ResponsiveProp<EmptyStateSize>;
children?: Snippet;
};
export type EmptyStateMediaProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** Media treatment — tinted icon box vs free media slot. @default 'icon' */
kind?: EmptyStateMediaKind;
children?: Snippet;
};
export type EmptyStateTitleProps = Omit<HTMLAttributes<HTMLHeadingElement>, 'children'> & {
/**
* Semantic heading level (`<h{level}>`). Visual size stays on the
* component `size` axis — level and look are decoupled. @default 3
*/
level?: EmptyStateTitleLevel;
children?: Snippet;
};
export type EmptyStateDescriptionProps = Omit<HTMLAttributes<HTMLParagraphElement>, 'children'> & {
children?: Snippet;
};
export type EmptyStateActionsProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/**
* Accessible name for the action group (Atlaskit `buttonGroupLabel`).
* When set, the part renders `role="group"` + `aria-label`.
*/
label?: string;
children?: Snippet;
};

@ -3207,6 +3207,12 @@
--banner-loss-track: var(--color-loss-track);
--banner-loss-border: var(--color-loss-border);
--banner-loss-text: var(--color-loss-text);
--empty-state-gap: var(--space-3);
--empty-state-actions-gap: var(--space-2);
--empty-state-media-bg: var(--color-neutral-track);
--empty-state-media-fg: var(--color-content-muted);
--empty-state-media-radius: var(--radius-lg);
--empty-state-description-measure: 45ch;
--container-max-width: var(--container-width-xl);
--section-padding-block-sm: var(--space-8);
--section-padding-block-md: var(--space-12);

@ -4429,6 +4429,20 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'loss-text': 'var(--color-loss-text)'
},
// ─────────────────────────────────────────────────────────────────────
// EmptyState — passive "nothing here / no results" surface. Public
// surface is intentionally small: spacing + the media plate + the
// description reading measure; density rides data-size internally.
// ─────────────────────────────────────────────────────────────────────
'empty-state': {
gap: 'var(--space-3)',
'actions-gap': 'var(--space-2)',
'media-bg': 'var(--color-neutral-track)',
'media-fg': 'var(--color-content-muted)',
'media-radius': 'var(--radius-lg)',
'description-measure': '45ch'
},
// ─────────────────────────────────────────────────────────────────────
// Container — full-bleed wrapper that caps `max-width` at the canonical
// page width by default: the xl container token (= breakpoint xl, 1280 —

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const emptyStateLangs = {
label: {
es: 'Estado vacío',
en: 'Empty state'
}
} satisfies LangNode;

@ -37,6 +37,7 @@ import { drawerLangs } from './drawer';
import { dropdownMenuLangs } from './dropdown-menu';
import { editableLangs } from './editable';
import { emojiPickerLangs } from './emoji-picker';
import { emptyStateLangs } from './empty-state';
import { fabLangs } from './fab';
import { feedLangs } from './feed';
import { fieldLangs } from './field';
@ -108,7 +109,6 @@ import { treeGridLangs } from './tree-grid';
import { treeViewLangs } from './tree-view';
import { virtualGridLangs } from './virtual-grid';
import { virtualListLangs } from './virtual-list';
import { wordsLangs } from './words';
import { yearGridLangs } from './year-grid';
/**
@ -154,6 +154,7 @@ export const componentLangs = {
'dropdown-menu': dropdownMenuLangs,
editable: editableLangs,
'emoji-picker': emojiPickerLangs,
'empty-state': emptyStateLangs,
fab: fabLangs,
feed: feedLangs,
field: fieldLangs,
@ -225,6 +226,5 @@ export const componentLangs = {
'tree-view': treeViewLangs,
'virtual-grid': virtualGridLangs,
'virtual-list': virtualListLangs,
words: wordsLangs,
'year-grid': yearGridLangs
} satisfies LangNode;

@ -0,0 +1,96 @@
import type { Morfo } from '../types';
/**
* EmptyState — passive display surface for "nothing here yet / no results"
* states: media (icon box or free illustration) + title + description +
* actions, centered.
*
* Eidos-native (`scope: ['eidos']`): pure composition surface with no
* behavior, no state machine and no perceptual occurrences of its own —
* the same passive justification as `banner.ts` / `box.ts`. The actions
* slot hosts consumer-provided `Button`s wired to consumer handlers
* (retry / create / clear-filters); EmptyState itself commits nothing.
*
* Justification for 0 events: appearing/disappearing is a fact of the
* COLLECTION that renders it (table/list/select), not of this surface —
* announcing it belongs to the collection's live region, mirroring how
* headless references model empties as a render slot of collections
* (React Aria `renderEmptyState`, HeroUI `emptyContent`).
*
* Anatomy mirrors the converged reference floor (shadcn `Empty`, Chakra v3
* `EmptyState`, AntD `Empty`, Polaris, Atlaskit — dossier
* `docs/process/RESEARCH-blocks-references.md` §P3): provider / media /
* title / description / actions. Title carries `role: 'heading'` intent
* (rendered as a real `<h{level}>` by the eidos wrapper — Atlaskit's
* configurable `headingLevel` model; visual size stays decoupled on the
* component `size` axis). The actions group takes an accessible name via
* the wrapper (`label` → `aria-label`, Atlaskit `buttonGroupLabel`).
*/
export const emptyStateMorfo = {
name: 'EmptyState',
kebab: 'empty-state',
scope: ['eidos'],
apg: 'none — passive display surface; the composed actions carry the button semantics of their own components',
texts: {
label: '#?components.empty-state.label|Empty state'
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
},
{
name: 'Media',
kebab: 'media',
archetype: 'image',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Rendered as `<h{level}>` (default h3) by the eidos wrapper.
// MorfoElement doesn't enumerate heading levels, so we declare
// `'div'` + `role: 'heading'` to keep the semantic intent visible
// in the contract (same convention as card.ts Title).
name: 'Title',
kebab: 'title',
archetype: 'title',
kind: 'public',
defaultElement: 'div',
role: 'heading',
optional: true,
data: [],
aria: []
},
{
// Rendered as `<p>` by the eidos wrapper. MorfoElement doesn't
// enumerate `'p'` — closest layout-neutral element here.
name: 'Description',
kebab: 'description',
archetype: 'description',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Actions',
kebab: 'actions',
archetype: 'footer',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -254,6 +254,7 @@
heading: 'Status',
items: [
{ slug: '/uix/components/badge', label: 'Badge' },
{ slug: '/uix/components/empty-state', label: 'Empty state' },
{ slug: '/uix/components/progress', label: 'Progress' },
{ slug: '/uix/components/meter', label: 'Meter' },
{ slug: '/uix/components/spinner', label: 'Spinner' },

@ -0,0 +1,623 @@
<script lang="ts">
import {
EmptyState,
type EmptyStateSize,
type EmptyStateMediaKind,
type EmptyStateTitleLevel
} from '$uix/eidos/components/empty-state';
import { Button } from '$uix/eidos/components/button';
import { Card } from '$uix/eidos/components/card';
import { compileMorfo } from '$uix/morfo';
import { emptyStateMorfo } from '@/uix/morfo/components/empty-state';
import { getActiveUix } from '$active-uix';
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Stage + System axes ──────────────────────────────────────────────
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
if (stageRef) return trace.observe(stageRef);
});
let density = $state('comfortable');
let scaling = $state(1);
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
let dir = $state<'ltr' | 'rtl'>('ltr');
let borderWidth = $state(1);
// ── Live state ───────────────────────────────────────────────────────
const sizes: EmptyStateSize[] = ['sm', 'md', 'lg'];
const kinds: EmptyStateMediaKind[] = ['icon', 'media'];
const levels: EmptyStateTitleLevel[] = [2, 3, 4, 5, 6];
let size = $state<EmptyStateSize>('md');
let kind = $state<EmptyStateMediaKind>('icon');
let level = $state<EmptyStateTitleLevel>(3);
let withMedia = $state(true);
let withDescription = $state(true);
let withActions = $state(true);
let withSecondary = $state(false);
let inCollection = $state(false);
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(emptyStateMorfo);
const partsList = [...compiled.parts.byKebab.values()];
const events = [...compiled.actions.byName.values()];
// ── Code snippet (parity with the live controls) ─────────────────────
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { EmptyState } from '$uix/eidos/components/empty-state';",
" import { Button } from '$uix/eidos/components/button';",
'</' + 'script>',
'',
`<EmptyState size="${size}">`,
withMedia && ` <EmptyState.Media kind="${kind}">…</EmptyState.Media>`,
level === 3
? ' <EmptyState.Title>No projects yet</EmptyState.Title>'
: ` <EmptyState.Title level={${level}}>No projects yet</EmptyState.Title>`,
withDescription &&
' <EmptyState.Description>Create your first project to get started.</EmptyState.Description>',
withActions && ' <EmptyState.Actions label="Empty state actions">',
withActions && ' <Button>Create project</Button>',
withActions && withSecondary && ' <Button variant="ghost">Learn more</Button>',
withActions && ' </EmptyState.Actions>',
'</EmptyState>'
]
.filter(Boolean)
.join('\n')
);
</script>
{#snippet demoEmptyState()}
<EmptyState {size}>
{#if withMedia}
<EmptyState.Media {kind}>
{#if kind === 'icon'}
<svg viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true" focusable="false">
<path
d="M3 13h4l2 3h6l2-3h4M5 6h14l2 7v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5l2-7Z"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linejoin="round"
/>
</svg>
{:else}
<svg viewBox="0 0 96 56" width="96" height="56" aria-hidden="true" focusable="false">
<rect
x="6"
y="6"
width="84"
height="44"
rx="8"
fill="none"
stroke="currentColor"
stroke-width="2"
opacity="0.4"
/>
<circle cx="30" cy="24" r="7" fill="currentColor" opacity="0.35" />
<path
d="M16 44l16-14 12 10 14-12 22 16"
fill="none"
stroke="currentColor"
stroke-width="2"
opacity="0.5"
/>
</svg>
{/if}
</EmptyState.Media>
{/if}
<EmptyState.Title {level}>No projects yet</EmptyState.Title>
{#if withDescription}
<EmptyState.Description>
Create your first project to get started. Imported projects appear here too.
</EmptyState.Description>
{/if}
{#if withActions}
<EmptyState.Actions label="Empty state actions">
<Button>Create project</Button>
{#if withSecondary}
<Button variant="ghost">Learn more</Button>
{/if}
</EmptyState.Actions>
{/if}
</EmptyState>
{/snippet}
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Status · Empty state</div>
<h1 data-uix-page-title>Empty state</h1>
<p data-uix-page-lede>
Passive "nothing here yet / no results" surface: media + title + description + actions,
centered. Display-only and eidos-native — the composed
<a href="/uix/components/button">&lt;Button&gt;</a>s carry the behavior, and announcing an
empty's appearance belongs to the collection that renders it. <code>size="sm"</code> is the
compact in-collection density (tables, selects, command palettes); <code>lg</code> the
full-page hero. For terminal flow states (success / error / 404) use the upcoming
<code>Result</code> instead.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>sizes</span>{sizes.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Always-on stage — carries the System axes -->
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={mode === 'inherit' ? undefined : mode}
data-mode={mode === 'inherit' ? undefined : mode}
{dir}
style={`--scaling: ${scaling}; --border-width: ${borderWidth}px;`}
>
{#if inCollection}
<Card style="inline-size: min(100%, 34rem);">
{@render demoEmptyState()}
</Card>
{:else}
{@render demoEmptyState()}
{/if}
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>
{trace.entries.length === 0 ? 'no semantic events' : `${trace.entries.length} event(s)`}
</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>size</span>
<span>{size}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>media</span>
{withMedia ? kind : 'off'} ·
<span data-uix-stage-trace-key>level</span> h{level}
</span>
</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 === 'system'} onclick={() => (tab = 'system')}>
System
</button>
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>
Motion
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>
Services
</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>
Recipe
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
EmptyState is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. It has
no <code>color</code> prop by design: it is a neutral display surface (media plate colors
are theme tokens, overridable via <code>recipes.empty-state.*</code>).
</p>
<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>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as opt (opt)}
<button data-uix-chip data-active={size === opt} onclick={() => (size = opt)}>
{opt}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>media kind</span>
<span data-uix-chips role="radiogroup">
{#each kinds as opt (opt)}
<button data-uix-chip data-active={kind === opt} onclick={() => (kind = opt)}>
{opt}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>title level</span>
<span data-uix-chips role="radiogroup">
{#each levels as opt (opt)}
<button data-uix-chip data-active={level === opt} onclick={() => (level = opt)}>
h{opt}
</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>Composition</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>media</span>
<input type="checkbox" bind:checked={withMedia} />
</label>
<label data-uix-control>
<span data-uix-control-label>description</span>
<input type="checkbox" bind:checked={withDescription} />
</label>
<label data-uix-control>
<span data-uix-control-label>actions</span>
<input type="checkbox" bind:checked={withActions} />
</label>
<label data-uix-control>
<span data-uix-control-label>secondary action</span>
<input type="checkbox" bind:checked={withSecondary} />
</label>
<label data-uix-control>
<span data-uix-control-label>inside a Card (in-collection)</span>
<input type="checkbox" bind:checked={inCollection} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>composition · size / media kind / heading level</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'system'}
<section data-uix-section>
<h2 data-uix-section-title>System axes</h2>
<p data-uix-section-desc>
Foundation knobs applied to the stage — density, scaling, mode, direction and border width.
EmptyState's typography rides the <code>--size-*</code> bundle, so density and scaling re-derive
it coherently.
</p>
<SystemAxes bind:density bind:scaling bind:mode bind:dir bind:borderWidth />
</section>
{/if}
{#if tab === 'motion'}
<section data-uix-section>
<h2 data-uix-section-title>Motion</h2>
<MotionPanel
note="EmptyState has no motion prop and no entrance animation of its own — collections decide how an empty appears (usually a plain swap). For choreographed entrances compose Cascade around it."
/>
</section>
{/if}
{#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>
EmptyState declares no semantic events. It is a passive surface: appearing/disappearing is a
fact of the COLLECTION that renders it (table / list / select), and announcing it belongs to
that collection's live region — mirroring how headless references model empties as a render
slot (<code>renderEmptyState</code> in React Aria, <code>emptyContent</code> in HeroUI). The
composed <code>&lt;Button&gt;</code>s in <code>.Actions</code> own their own sema.
</p>
<SemaPanel
actions={events}
{uix}
getTarget={() => stageRef?.querySelector('[data-empty-state]') ?? stageRef}
/>
</section>
{/if}
{#if tab === 'services'}
<section data-uix-section>
<h2 data-uix-section-title>Services</h2>
<p data-uix-section-desc>
EmptyState consumes <strong>langs</strong> only for its catalog display name (<code
>texts.label</code
>
→ «{uix.langs.ts('#?components.empty-state.label|Empty state')}» — switch the language in
the topbar to see it follow). All VISIBLE copy (title, description, action labels) is app
content by design — the component bakes no strings, so it is translation-ready by
construction. No format / announce / clipboard consumption.
</p>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-subsection-head>EmptyState props</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">size</td>
<td class="type">{sizes.join(' | ')}</td>
<td>
Density. <code>sm</code> = compact in-collection empty, <code>lg</code> = full-page
hero. Default <code>md</code>. Responsive.
</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Prop</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">EmptyState.Media</td>
<td class="type">kind: {kinds.join(' | ')}</td>
<td>
<code>icon</code> paints the tinted icon box; <code>media</code> is a free slot for
illustrations / avatar groups. Default <code>icon</code>.
</td>
</tr>
<tr>
<td class="name">EmptyState.Title</td>
<td class="type">level: 2 | 3 | 4 | 5 | 6</td>
<td>
Real <code>&lt;h{'{level}'}&gt;</code> (Atlaskit model). Semantic level and visual
size are decoupled — the look rides <code>size</code>. Default
<code>3</code>.
</td>
</tr>
<tr>
<td class="name">EmptyState.Description</td>
<td class="type">—</td>
<td>Muted <code>&lt;p&gt;</code>, capped to a readable measure (45ch token).</td>
</tr>
<tr>
<td class="name">EmptyState.Actions</td>
<td class="type">label?: string</td>
<td>
Hosts consumer <code>&lt;Button&gt;</code>s — one primary (verb-first), optional
secondary/ghost. <code>label</code> names the group (<code>role="group"</code> +
<code>aria-label</code>).
</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>Empty / EmptyMedia / EmptyTitle / …</code></td>
<td>
Same anatomy incl. <code>EmptyMedia variant="icon"</code> (our
<code>kind</code>); shadcn adds an <code>EmptyHeader</code> wrapper we don't need (the
root centers directly).
</td>
</tr>
<tr>
<td class="name">chakra v3</td>
<td><code>EmptyState.Root/Indicator/Title/Description</code></td>
<td>Same slot recipe + <code>size</code>; no heading-level control.</td>
</tr>
<tr>
<td class="name">atlaskit</td>
<td><code>EmptyState</code></td>
<td>
Source of <code>headingLevel</code> (their default h4; ours h3) and
<code>buttonGroupLabel</code> (our <code>Actions label</code>).
</td>
</tr>
<tr>
<td class="name">antd</td>
<td><code>Empty</code></td>
<td>
Ships image presets + <code>ConfigProvider.renderEmpty</code> global injection — collection
integration is a tracked gap here (see README §Gaps).
</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>{emptyStateMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{emptyStateMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{emptyStateMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</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="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
Title declares <code>role: 'heading'</code> intent in the contract; the eidos wrapper
renders the real <code>&lt;h{'{level}'}&gt;</code>. The wrapper-only visual attrs (<code
>data-size</code
>
on the root, <code>data-kind</code> on media) are eidos-only by design — they never cross to another
layer.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/empty-state/empty-state.css</code>. Public
tokens:
<code
>--empty-state-gap · actions-gap · media-bg · media-fg · media-radius ·
description-measure</code
>
(overridable via <code>recipes.empty-state.*</code>). Typography rides the
<code>--size-{'{k}'}-*</code> bundle — the title reads one discrete size up from the component
size.
</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-empty-state]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker — centered column, gap + padding.</td>
</tr>
<tr>
<td class="name"><code>[data-empty-state][data-size='X']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Density — paddings + title/body font sizes + media glyph size.</td>
</tr>
<tr>
<td class="name"><code>[data-empty-state-media][data-kind='icon']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>The tinted icon plate (2× glyph box, neutral track background).</td>
</tr>
<tr>
<td class="name"><code>[data-empty-state-title]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Semibold heading, one bundle size above the component size.</td>
</tr>
<tr>
<td class="name"><code>[data-empty-state-description]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Muted body copy, <code>max-inline-size</code> reading measure.</td>
</tr>
<tr>
<td class="name"><code>[data-empty-state-actions]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Wrapping centered action row.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Role</td>
<td>
None on the root — a static display surface, not a live region and not a landmark.
Announcing "no results" belongs to the collection that renders it.
</td>
</tr>
<tr>
<td class="name">Heading</td>
<td>
Title is a real <code>&lt;h{'{level}'}&gt;</code> (default h3) so the empty participates
correctly in the document outline; pick the level that fits the page hierarchy.
</td>
</tr>
<tr>
<td class="name">Actions group</td>
<td>
<code>Actions label</code> renders <code>role="group"</code> +
<code>aria-label</code> — an accessible name for multi-action rows (Atlaskit
<code>buttonGroupLabel</code>).
</td>
</tr>
<tr>
<td class="name">Media</td>
<td>
Decorative by default — pass <code>aria-hidden</code> glyphs or meaningful
<code>alt</code>s on real images; the plate itself carries no semantics.
</td>
</tr>
<tr>
<td class="name">Content doctrine</td>
<td>
One primary action, verb-first ("Create project", never "OK"); description states
the reason + the next step (Polaris / Atlassian writing guidance).
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.