uix(result): F1.3 · estado terminal de flujo/pagina (morfo+eidos) al suelo del dossier

Segunda pieza F1 del plan blocks (alcance E-2 = suelo de paridad, dossier
§P3). Comparte el esqueleto de empty-state a proposito: EmptyState describe
AUSENCIA de datos, Result reporta un RESULTADO — un lenguaje de layout, dos
contratos (duplicacion consciente registrada en el README; revisable al 3er
consumidor).

- morfo: 6 partes display (provider/media/title/description/actions/extra;
  extra SIN archetype — parte genuinamente propia), scope ['eidos'],
  0 eventos justificados (el resultado ya OCURRIO antes de renderizar)
- status: enum de 7 = paridad AntD CON `warning` (decision firme del
  dossier) y HTTP renombrados semanticos (forbidden/not-found/server-error,
  nunca '404' stringly); default 'info'; data-status = attr eidos-only
- media default: compone el mapa doctrinal IntentIcon (success→fulfill ·
  error→threat · warning→risk) + Info de catalogo; HTTP = codigo mono
  grande NEUTRO aria-hidden (situaciones, no fallos — AntD jamas pinta 404
  de rojo); children reemplazan el default entero (context local eidos-only
  con getter reactivo)
- Title default h2 (vs h3 de empty-state — Result suele SER la pagina);
  Actions con label→role=group; Extra alineado a inicio (detalle que lee)
- recipe: glifo display 3× xl bundle; tokens publicos
  gap/actions-gap/measures + {status}-color como forwarders de rol
  retintables; sin eje size (paridad AntD, gap diferido)
- demo v2 9 tabs con copy por status + entrada nav (Status) + README
  (Comparativa · Decisiones · Passive justification · Gaps con disposicion)

Verificacion: component:audit PASS 0E/0W · eidos-lint 6 morfo-backed + 5
eidos-only sancionados · recipe/api/visual-attrs 37/37 · morfo:check verde
(los 7 fallos listados son deuda foranea preexistente) · svelte-check
76E/51W = baseline exacto · navegador: success=fulfill verde 84px ·
error=threat rojo · not-found=«404» mono neutro, copy conmutando, cero
errores de consola.

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

@ -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) | en curso — **F1.2 `empty-state` HECHA 2026-07-21** (PASS; ver registro) |
| **F1** | **8** componentes base del CANON que los blocks necesitan (7 + `nav-tree` por E-1) | en curso — **F1.2 + F1.3 HECHAS 2026-07-21** (empty-state · result, ambas 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 |
@ -842,3 +842,18 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
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).
- 2026-07-21 — **F1.3 `result` HECHA** (ruta completa, suelo E-2 §P3):
morfo 6 partes (`extra` sin archetype — parte genuinamente propia) ·
status enum de 7 CON `warning` (decisión del dossier firme), default
`info`, HTTP renombrados semánticos y renderizados NEUTROS (código mono
grande, jamás rojo — doctrina AntD) · media default compone el mapa
doctrinal `IntentIcon` (fulfill/threat/risk) + `Info` de catálogo,
children lo reemplazan entero (context local eidos-only con getter
reactivo) · Title default h2 (vs h3 de empty-state — deliberado) ·
esqueleto duplicado conscientemente (README lo registra; revisable al
3er consumidor) · sin eje size (paridad AntD). Verificado:
`component:audit` **PASS 0E/0W** · eidos-lint 6 morfo-backed + 5
eidos-only · guards recipe 37/37 · navegador: success=fulfill verde
84px · error=threat rojo · not-found=«404» mono NEUTRO aria-hidden,
copy conmutando, cero errores consola. Siguiente: **F1.4 `callout`**
(decisión IMPORTANT/5º hueco + role=note; dossier §P3).

@ -0,0 +1,81 @@
# Result
Terminal flow/page state: an operation or route resolved and this surface
reports how. Built 2026-07-21 as F1.3 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']`).
No soma provider, no sema pack. Shares EmptyState's skeleton
deliberately — EmptyState describes ABSENCE of data, Result reports an
OUTCOME; one layout language, two contracts.
- **Anatomy**: `Result` (root, eidos-only `data-status`) → `.Media`
(status default or full replacement) + `.Title` (real `<h{level}>`,
default h2) + `.Description` (measure 45ch) + `.Actions` (label →
`role="group"`) + `.Extra` (detail block, left-aligned).
- **Status vocabulary** (7 = AntD parity, semantically named):
`success | error | info | warning | forbidden | not-found |
server-error`. Default `info` (AntD default; undefined default = contract
smell).
- **Default media** composes the doctrinal glyph map — no local
intent→icon table (icon/intent.ts doctrine): success→`IntentIcon
fulfill` (sealed completion) · error→`threat` · warning→`risk` ·
info→catalog `Info`; the HTTP situations render a big NEUTRAL mono code
(403/404/500) — situations, not failures (AntD never paints 404 red).
- **No size axis** by design: Result is page-level (AntD parity).
## Comparativa
| Ref | Equivalente | Qué adoptamos | Qué no |
| ------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| AntD `Result` | la única ref de primera clase | el enum de 7 (con `warning`), default `info`, `extra` como bloque de detalle, icon-override vía children, HTTP en neutro | `'403'\|'404'\|'500'` stringly-typed (renombrados semánticos); su ilustración propietaria (cero assets horneados) |
| Tailwind Plus 404 Pages | páginas de error (marketing) | la anatomía code-eyebrow + heading + CTA (nuestro `data-result-code`) | dumps congelados por página |
| Chakra / Polaris / Radix / Ark | — (ninguno lo shippea) | la confirmación de que el esqueleto correcto es el del empty-state | — |
| Flowbite | 404/500/maintenance como blocks | maintenance queda para el block `error-page` (F3.9), no para este componente | — |
## Decisiones
- **`warning` entra** (7º valor): el dossier señalaba su ausencia como
decisión explícita; mapea natural al glifo del tier `risk`.
- **`data-status` ≠ intent perceptivo**: nombra la situación; el recipe lo
mapea a color de rol SOLO para el glifo (los tokens
`recipes.result.{status}-color` son forwarders de rol retintables). El
texto nunca depende del color.
- **HTTP en neutro**: 403/404/500 son situaciones — código grande mono
muted, jamás rojo (doctrina AntD ratificada por el dossier).
- **Title default h2** (EmptyState: h3): Result suele SER la página. La
diferencia es deliberada y está documentada en ambos README.
- **Esqueleto duplicado conscientemente**: el layout centrado (~30 líneas)
se repite en `result.css` en vez de crear una capa CSS compartida para 2
consumidores — los recipes divergen ya (glyph display-scale, código
HTTP, extra izquierdo) y la independencia lee mejor que la indirección.
Revisable si aparece un tercer consumidor del esqueleto.
- **Context local eidos-only** (`context.ts`): `Media` conoce el `status`
del root sin re-pasarlo; getter reactivo, nunca snapshot.
## Passive justification
Result declares 0 events because the outcome it reports already happened
BEFORE this surface rendered: the perceptual signal belonged to the
operation that resolved (its form's commit, its toast, its dialog). Result
is the report, not the occurrence. The composed `Button`s in `.Actions`
own the next step's behavior and sema. No keyboard contract, no state
machine, no ARIA obligations beyond the optional named actions group —
same passive class as `banner` / `empty-state`.
## Sema events
None (see Passive justification). `SemaPanel` in the demo renders the
justified empty state.
## Gaps
| Gap | Disposition |
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Focus/announce tras navegación SPA (mover foco al título o live region polite) | **diferir** — es cableado del APP (routing), no del componente; patrón documentado en la tab A11y de la demo; ninguna referencia lo implementa tampoco. Candidato a doctrina de blocks cuando `error-page` (F3.9) lo componga |
| Variante `maintenance` | **diferir** al block `error-page` (F3.9) — es una página, no un status del componente (doctrina Flowbite) |
| Eje `size` (Result compacto en modal) | **diferir** — AntD no lo tiene (suelo cumplido); si un consumidor real lo pide, se añade con la misma mecánica que EmptyState |
| Ilustraciones por status | **descartar** — cero assets horneados; `Media` con children reemplaza el default entero |

@ -0,0 +1,21 @@
import { getContext, setContext } from 'svelte';
import type { ResultStatus } from './types';
/**
* Local eidos-only context: lets `Result.Media` render the default glyph
* for the root's `status` without the consumer re-passing it. Carries a
* reactive getter (Svelte 5 pattern) — never a snapshot.
*/
const RESULT_CTX = Symbol('uix-eidos-result');
export interface ResultContext {
readonly status: ResultStatus;
}
export function setResultContext(ctx: ResultContext): void {
setContext(RESULT_CTX, ctx);
}
export function getResultContext(): ResultContext | undefined {
return getContext<ResultContext | undefined>(RESULT_CTX);
}

@ -0,0 +1,46 @@
// Result — terminal flow/page state display surface.
//
// import { Result } from '$uix/eidos/components/result';
//
// <Result status="not-found">
// <Result.Media />
// <Result.Title>Page not found</Result.Title>
// <Result.Description>The page you visited does not exist.</Result.Description>
// <Result.Actions label="Result actions">
// <Button>Back home</Button>
// </Result.Actions>
// </Result>
import ResultComponent from './result.svelte';
import Media from './result-media.svelte';
import Title from './result-title.svelte';
import Description from './result-description.svelte';
import Actions from './result-actions.svelte';
import Extra from './result-extra.svelte';
type ResultNamespace = typeof ResultComponent & {
Media: typeof Media;
Title: typeof Title;
Description: typeof Description;
Actions: typeof Actions;
Extra: typeof Extra;
};
const Result = ResultComponent as ResultNamespace;
Result.Media = Media;
Result.Title = Title;
Result.Description = Description;
Result.Actions = Actions;
Result.Extra = Extra;
export { Result };
export default Result;
export type {
ResultProps,
ResultMediaProps,
ResultTitleProps,
ResultDescriptionProps,
ResultActionsProps,
ResultExtraProps,
ResultStatus,
ResultTitleLevel
} from './types';

@ -0,0 +1,20 @@
<script lang="ts">
/**
* Eidos `<Result.Actions>` — the action row (home / retry / back).
* Same contract as `EmptyState.Actions`: hosts consumer `Button`s,
* one primary; `label` names the group (`role="group"` + `aria-label`).
*/
import type { ResultActionsProps } from './types';
let { label, class: className, children, ...restProps }: ResultActionsProps = $props();
</script>
<div
{...restProps}
class={className}
role={label ? 'group' : undefined}
aria-label={label}
data-result-actions=""
>
{@render children?.()}
</div>

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

@ -0,0 +1,14 @@
<script lang="ts">
/**
* Eidos `<Result.Extra>` — additional content block under the actions
* (AntD `children`): error detail lists, ticket ids, related links.
* Left-aligned by the recipe (detail reads, it doesn't announce).
*/
import type { ResultExtraProps } from './types';
let { class: className, children, ...restProps }: ResultExtraProps = $props();
</script>
<div {...restProps} class={className} data-result-extra="">
{@render children?.()}
</div>

@ -0,0 +1,43 @@
<script lang="ts">
/**
* Eidos `<Result.Media>` — the status visual. Without children it
* renders the DEFAULT for the root `status`: the doctrinal intent
* glyph (`IntentIcon` — success→fulfill · error→threat ·
* warning→risk; no consumer hardcodes its own intent→icon table),
* the catalog `Info` glyph for `info`, or the big HTTP code text for
* the three situations (forbidden 403 · not-found 404 · server-error
* 500 — rendered NEUTRAL: situations, not failures). Children replace
* the default entirely (illustration, custom art).
*/
import { Info, IntentIcon } from '$uix/eidos/components/icon';
import { getResultContext } from './context';
import type { ResultMediaProps } from './types';
let { class: className, children, ...restProps }: ResultMediaProps = $props();
const ctx = getResultContext();
const status = $derived(ctx?.status ?? 'info');
const HTTP_CODES = { forbidden: '403', 'not-found': '404', 'server-error': '500' } as const;
const code = $derived(
status === 'forbidden' || status === 'not-found' || status === 'server-error'
? HTTP_CODES[status]
: undefined
);
</script>
<div {...restProps} class={className} data-result-media="">
{#if children}
{@render children()}
{:else if code}
<span data-result-code="" aria-hidden="true">{code}</span>
{:else if status === 'success'}
<IntentIcon intent="fulfill" size="1em" />
{:else if status === 'error'}
<IntentIcon intent="threat" size="1em" />
{:else if status === 'warning'}
<IntentIcon intent="risk" size="1em" />
{:else}
<Info size="1em" />
{/if}
</div>

@ -0,0 +1,14 @@
<script lang="ts">
/**
* Eidos `<Result.Title>` — real heading, default `<h2>` (Result is a
* page-level surface; EmptyState defaults h3 — the difference is
* deliberate). Visual size is fixed by the recipe.
*/
import type { ResultTitleProps } from './types';
let { level = 2, class: className, children, ...restProps }: ResultTitleProps = $props();
</script>
<svelte:element this={`h${level}`} {...restProps} class={className} data-result-title="">
{@render children?.()}
</svelte:element>

@ -0,0 +1,102 @@
/*
* Result recipe — terminal flow/page state. Centered column: media
* (status glyph or big HTTP code) + title + description + actions +
* extra. Shares EmptyState's layout language deliberately (one skeleton,
* two contracts); no size axis — Result is page-level (AntD parity).
*
* `data-status` maps to role colors for the GLYPH only: success→fulfill
* (sealed completion) · error→threat · warning→risk · info→primary. The
* three HTTP situations render NEUTRAL big-code text — they are
* situations, not failures (AntD never paints 404 red).
*
* `--result-*` tokens are public overrides; `--_result-*` internal.
*/
[data-result] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--result-gap, var(--space-3));
padding-block: var(--space-12);
padding-inline: var(--space-5);
min-inline-size: 0;
box-sizing: border-box;
}
/* ── Media ─────────────────────────────────────────────────────────── */
[data-result-media] {
display: flex;
align-items: center;
justify-content: center;
font-size: calc(var(--size-xl-font-size) * 3); /* literal: display glyph 3× */
line-height: 1;
color: var(--_result-media-color, var(--color-content-muted));
}
[data-result][data-status='success'] {
--_result-media-color: var(--result-success-color);
}
[data-result][data-status='error'] {
--_result-media-color: var(--result-error-color);
}
[data-result][data-status='warning'] {
--_result-media-color: var(--result-warning-color);
}
[data-result][data-status='info'] {
--_result-media-color: var(--result-info-color);
}
/* The big HTTP code (forbidden / not-found / server-error) — neutral. */
[data-result-code] {
font-family: var(--font-family-mono, monospace);
font-size: 1em; /* literal: identity — inherits the media display scale */
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em; /* literal: optical tracking for the big mono code */
color: var(--color-content-muted);
}
/* ── Title ─────────────────────────────────────────────────────────── */
[data-result-title] {
margin: 0;
font-size: var(--size-xl-font-size);
font-weight: var(--font-weight-semibold);
line-height: var(--font-line-height-sm, 1.4);
color: var(--color-content-primary);
}
/* ── Description ───────────────────────────────────────────────────── */
[data-result-description] {
margin: 0;
font-size: var(--size-md-font-size);
line-height: var(--font-line-height-md, 1.5);
color: var(--color-content-muted);
max-inline-size: var(--result-description-measure, 45ch);
text-wrap: pretty;
}
/* ── Actions ───────────────────────────────────────────────────────── */
[data-result-actions] {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--result-actions-gap, var(--space-2));
margin-block-start: var(--space-1);
}
/* ── Extra ─────────────────────────────────────────────────────────── */
[data-result-extra] {
margin-block-start: var(--space-4);
max-inline-size: var(--result-extra-measure, 60ch);
inline-size: 100%;
text-align: start;
font-size: var(--size-sm-font-size);
color: var(--color-content-secondary);
}

@ -0,0 +1,38 @@
<script lang="ts">
import './result.css';
/**
* Eidos `<Result>` — terminal flow/page state (success · error · info ·
* warning · forbidden · not-found · server-error). Shares EmptyState's
* skeleton deliberately: EmptyState describes ABSENCE of data, Result
* reports an OUTCOME. Display only (eidos-native): the composed
* `Button`s in `.Actions` own the next step.
*
* <Result status="success">
* <Result.Media />
* <Result.Title>Payment complete</Result.Title>
* <Result.Description>We emailed the receipt to you.</Result.Description>
* <Result.Actions label="Result actions">
* <Button>Back home</Button>
* </Result.Actions>
* </Result>
*/
import { ActiveEidos } from '$uix/eidos';
import { setResultContext } from './context';
import type { ResultProps } from './types';
let { status = 'info', class: className, children, ...restProps }: ResultProps = $props();
// Membership guard — Result renders nothing meaningful without the
// visual runtime (token resolution); mirrors the EmptyState root.
ActiveEidos.require();
setResultContext({
get status() {
return status;
}
});
</script>
<div {...restProps} class={className} data-result="" data-status={status}>
{@render children?.()}
</div>

@ -0,0 +1,66 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
/**
* Result status vocabulary — AntD's 7-value enum with the HTTP codes
* renamed semantically (`'404'` as a prop value is stringly-typed).
* `warning` maps naturally to the `risk` glyph tier; the three HTTP
* situations render NEUTRAL (they are situations, not failures).
*/
export type ResultStatus =
| 'success'
| 'error'
| 'info'
| 'warning'
| 'forbidden'
| 'not-found'
| 'server-error';
/** Semantic heading levels the Title part may render. */
export type ResultTitleLevel = 1 | 2 | 3 | 4 | 5 | 6;
export type ResultProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/**
* The terminal state this surface reports. Drives the default media
* glyph (or big HTTP code) and its role color. @default 'info'
*/
status?: ResultStatus;
children?: Snippet;
};
export type ResultMediaProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/**
* Children replace the status default entirely (custom glyph,
* illustration, anything). Without children the part renders the
* doctrinal glyph for the root `status` (IntentIcon for the
* evaluative tiers, the catalog `Info` glyph for info, the big code
* text for HTTP situations).
*/
children?: Snippet;
};
export type ResultTitleProps = Omit<HTMLAttributes<HTMLHeadingElement>, 'children'> & {
/**
* Semantic heading level (`<h{level}>`). Result is a page-level
* surface — default `2`. Visual size is fixed by the recipe.
*/
level?: ResultTitleLevel;
children?: Snippet;
};
export type ResultDescriptionProps = Omit<HTMLAttributes<HTMLParagraphElement>, 'children'> & {
children?: Snippet;
};
export type ResultActionsProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/**
* Accessible name for the action group. When set, the part renders
* `role="group"` + `aria-label` (same contract as EmptyState).
*/
label?: string;
children?: Snippet;
};
export type ResultExtraProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
children?: Snippet;
};

@ -3207,6 +3207,14 @@
--banner-loss-track: var(--color-loss-track);
--banner-loss-border: var(--color-loss-border);
--banner-loss-text: var(--color-loss-text);
--result-gap: var(--space-3);
--result-actions-gap: var(--space-2);
--result-description-measure: 45ch;
--result-extra-measure: 60ch;
--result-success-color: var(--color-fulfill-solid);
--result-error-color: var(--color-threat-solid);
--result-warning-color: var(--color-risk-solid);
--result-info-color: var(--color-primary-solid);
--empty-state-gap: var(--space-3);
--empty-state-actions-gap: var(--space-2);
--empty-state-media-bg: var(--color-neutral-track);

@ -4429,6 +4429,22 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'loss-text': 'var(--color-loss-text)'
},
// ─────────────────────────────────────────────────────────────────────
// Result — terminal flow/page state. Status→role color forwarders for
// the glyph only (HTTP situations stay neutral in the recipe); spacing
// + reading measures. Shares EmptyState's layout language.
// ─────────────────────────────────────────────────────────────────────
result: {
gap: 'var(--space-3)',
'actions-gap': 'var(--space-2)',
'description-measure': '45ch',
'extra-measure': '60ch',
'success-color': 'var(--color-fulfill-solid)',
'error-color': 'var(--color-threat-solid)',
'warning-color': 'var(--color-risk-solid)',
'info-color': 'var(--color-primary-solid)'
},
// ─────────────────────────────────────────────────────────────────────
// EmptyState — passive "nothing here / no results" surface. Public
// surface is intentionally small: spacing + the media plate + the

@ -78,6 +78,7 @@ import { proofOfHumanLangs } from './proof-of-human';
import { radioGroupLangs } from './radio-group';
import { rangeCalendarLangs } from './range-calendar';
import { ratingGroupLangs } from './rating-group';
import { resultLangs } from './result';
import { rotateAlignLangs } from './rotate-align';
import { scrollAreaLangs } from './scroll-area';
import { searchFieldLangs } from './search-field';
@ -195,6 +196,7 @@ export const componentLangs = {
'radio-group': radioGroupLangs,
'range-calendar': rangeCalendarLangs,
'rating-group': ratingGroupLangs,
result: resultLangs,
'rotate-align': rotateAlignLangs,
'scroll-area': scrollAreaLangs,
'search-field': searchFieldLangs,

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const resultLangs = {
label: {
es: 'Resultado',
en: 'Result'
}
} satisfies LangNode;

@ -0,0 +1,106 @@
import type { Morfo } from '../types';
/**
* Result — terminal flow/page state: an operation or route resolved and
* this surface says how (success · error · info · warning · forbidden ·
* not-found · server-error). Media (status glyph or big HTTP code) +
* title + description + actions + extra content block, centered.
*
* Eidos-native (`scope: ['eidos']`): same passive membership as
* `empty-state` (the shared skeleton — deliberate: one layout language,
* two contracts). The `status` prop is stamped by the eidos wrapper as an
* eidos-only `data-status` attr (it never crosses to another layer, so it
* stays out of the contract — banner precedent). Status is NOT the
* perceptual intent: it names the situation; the recipe maps it to role
* colors (HTTP statuses stay NEUTRAL — they are situations, not failures;
* AntD does the same and never paints 404 red).
*
* Justification for 0 events: the outcome already happened before this
* surface rendered — Result reports it, the composed `Button`s in Actions
* own the next step. Announcing a terminal state after SPA navigation is
* the app's routing concern (move focus to the title / polite live
* region), documented in the README — no reference implements it either.
*
* Anatomy floor: AntD `Result` (the only first-class reference —
* status/title/subTitle/extra/icon/children) + the Tailwind error-page
* anatomy (code eyebrow + heading + description + home CTA). Dossier
* `docs/process/RESEARCH-blocks-references.md` §P3.
*/
export const resultMorfo = {
name: 'Result',
kebab: 'result',
scope: ['eidos'],
apg: 'none — passive display surface; the composed actions carry the button semantics of their own components',
texts: {
label: '#?components.result.label|Result'
},
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 h2 — Result is page-level) by
// the eidos wrapper. Same `'div'` + `role: 'heading'` convention
// as card.ts / empty-state.ts.
name: 'Title',
kebab: 'title',
archetype: 'title',
kind: 'public',
defaultElement: 'div',
role: 'heading',
optional: true,
data: [],
aria: []
},
{
// Rendered as `<p>` by the eidos wrapper.
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: []
},
{
// Additional content block under the actions (AntD `children`):
// error detail lists, ticket ids, related links. Genuinely
// component-specific — no archetype (types.ts: omit when unique).
name: 'Extra',
kebab: 'extra',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;

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

@ -0,0 +1,586 @@
<script lang="ts">
import { Result, type ResultStatus, type ResultTitleLevel } from '$uix/eidos/components/result';
import { Button } from '$uix/eidos/components/button';
import { compileMorfo } from '$uix/morfo';
import { resultMorfo } from '@/uix/morfo/components/result';
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 statuses: ResultStatus[] = [
'success',
'error',
'info',
'warning',
'forbidden',
'not-found',
'server-error'
];
const levels: ResultTitleLevel[] = [1, 2, 3, 4];
let status = $state<ResultStatus>('success');
let level = $state<ResultTitleLevel>(2);
let withMedia = $state(true);
let withDescription = $state(true);
let withActions = $state(true);
let withSecondary = $state(true);
let withExtra = $state(false);
// Demo copy per status (app content — the component bakes no strings).
const COPY: Record<ResultStatus, { title: string; description: string }> = {
success: { title: 'Payment complete', description: 'We emailed the receipt to you.' },
error: {
title: 'Submission failed',
description: 'Check the details below and try again.'
},
info: { title: 'Your request was queued', description: 'We will notify you when it runs.' },
warning: {
title: 'Saved with warnings',
description: 'Some optional fields were skipped.'
},
forbidden: {
title: 'You are not authorized',
description: 'Ask an administrator for access to this page.'
},
'not-found': {
title: 'Page not found',
description: 'The page you visited does not exist.'
},
'server-error': {
title: 'Something went wrong',
description: 'The server had a problem. Try again in a minute.'
}
};
const copy = $derived(COPY[status]);
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(resultMorfo);
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 { Result } from '$uix/eidos/components/result';",
" import { Button } from '$uix/eidos/components/button';",
'</' + 'script>',
'',
`<Result status="${status}">`,
withMedia && ' <Result.Media />',
level === 2
? ` <Result.Title>${copy.title}</Result.Title>`
: ` <Result.Title level={${level}}>${copy.title}</Result.Title>`,
withDescription && ` <Result.Description>${copy.description}</Result.Description>`,
withActions && ' <Result.Actions label="Result actions">',
withActions && ' <Button>Back home</Button>',
withActions && withSecondary && ' <Button variant="ghost">Contact support</Button>',
withActions && ' </Result.Actions>',
withExtra && ' <Result.Extra>…detail list…</Result.Extra>',
'</Result>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Status · Result</div>
<h1 data-uix-page-title>Result</h1>
<p data-uix-page-lede>
Terminal flow/page state: an operation or route resolved and this surface reports how —
<code>success · error · info · warning</code> plus the three HTTP situations
(<code>forbidden · not-found · server-error</code>, rendered NEUTRAL: situations, not
failures). Shares <a href="/uix/components/empty-state">&lt;EmptyState&gt;</a>'s skeleton
deliberately — EmptyState describes absence of data, Result reports an outcome. The default
media composes the doctrinal <code>IntentIcon</code> map (success→fulfill · error→threat ·
warning→risk); children replace it entirely.
</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>statuses</span>{statuses.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;`}
>
<Result {status}>
{#if withMedia}
<Result.Media />
{/if}
<Result.Title {level}>{copy.title}</Result.Title>
{#if withDescription}
<Result.Description>{copy.description}</Result.Description>
{/if}
{#if withActions}
<Result.Actions label="Result actions">
<Button>Back home</Button>
{#if withSecondary}
<Button variant="ghost">Contact support</Button>
{/if}
</Result.Actions>
{/if}
{#if withExtra}
<Result.Extra>
<ul style="margin: 0; padding-inline-start: var(--space-4);">
<li>Your card was not charged.</li>
<li>Reference: <code>REQ-2481</code> — include it when contacting support.</li>
</ul>
</Result.Extra>
{/if}
</Result>
</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>status</span>
<span>{status}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>level</span> h{level} ·
<span data-uix-stage-trace-key>extra</span>
{withExtra ? 'on' : 'off'}
</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>
Result is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split.
<code>status</code> drives an eidos-only <code>data-status</code> attr: the recipe colors
the default glyph by role (success→<code>fulfill</code> · error→<code>threat</code> ·
warning→<code>risk</code> · info→<code>primary</code>) and keeps the HTTP codes NEUTRAL.
No size axis by design (page-level surface — AntD parity).
</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>status</span>
<span data-uix-chips role="radiogroup">
{#each statuses as opt (opt)}
<button data-uix-chip data-active={status === opt} onclick={() => (status = 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 (status default)</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>extra (detail block)</span>
<input type="checkbox" bind:checked={withExtra} />
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>composition · status / heading level / extra</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. Result's typography reads fixed bundle sizes
(page-level surface), so density/scaling re-derive them 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="Result has no motion prop and no entrance animation of its own — it usually IS the page. For choreographed entrances compose Cascade around its parts."
/>
</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>
Result declares no semantic events: the outcome it reports already HAPPENED before this
surface rendered — the perceptual signal belonged to the operation that resolved (its
form, its toast, its dialog). The composed <code>&lt;Button&gt;</code>s in
<code>.Actions</code> own the next step's sema.
</p>
<SemaPanel
actions={events}
{uix}
getTarget={() => stageRef?.querySelector('[data-result]') ?? stageRef}
/>
</section>
{/if}
{#if tab === 'services'}
<section data-uix-section>
<h2 data-uix-section-title>Services</h2>
<p data-uix-section-desc>
Result consumes <strong>langs</strong> only for its catalog display name
(<code>texts.label</code> → «{uix.langs.ts('#?components.result.label|Result')}» — switch
the language in the topbar to see it follow). All visible copy (title, description,
actions, extra) is app content by design. 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>Result 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">status</td>
<td class="type">{statuses.join(' | ')}</td>
<td>
The terminal state. Default <code>info</code>. AntD's 7-value enum with the
HTTP codes renamed semantically (no stringly <code>'404'</code>).
</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">Result.Media</td>
<td class="type">—</td>
<td>
Empty = the status default (doctrinal <code>IntentIcon</code> glyph, catalog
<code>Info</code>, or the big neutral HTTP code). Children replace it entirely
(illustration, custom art).
</td>
</tr>
<tr>
<td class="name">Result.Title</td>
<td class="type">level: 1–6</td>
<td>
Real <code>&lt;h{'{level}'}&gt;</code>, default <code>2</code> (page-level;
EmptyState defaults 3 — deliberate difference).
</td>
</tr>
<tr>
<td class="name">Result.Description</td>
<td class="type">—</td>
<td>Muted <code>&lt;p&gt;</code>, 45ch measure token.</td>
</tr>
<tr>
<td class="name">Result.Actions</td>
<td class="type">label?: string</td>
<td>
Consumer <code>&lt;Button&gt;</code>s; <code>label</code> →
<code>role="group"</code> + <code>aria-label</code> (same contract as
EmptyState).
</td>
</tr>
<tr>
<td class="name">Result.Extra</td>
<td class="type">—</td>
<td>
Detail block under the actions (AntD <code>children</code>): error lists, ticket
ids. Left-aligned — detail reads, it doesn't announce.
</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">antd</td>
<td><code>Result</code></td>
<td>
The only first-class reference (status/title/subTitle/extra/icon). We rename
<code>'403'|'404'|'500'</code> semantically, keep its <code>warning</code>
slot, default <code>info</code> like it does, and mirror its neutral HTTP
treatment.
</td>
</tr>
<tr>
<td class="name">tailwind plus</td>
<td>404 Pages (marketing section)</td>
<td>
Frozen page dumps (code eyebrow + heading + CTA). Ours is a component with the
same anatomy, composable inside any layout.
</td>
</tr>
<tr>
<td class="name">chakra / polaris / radix / ark</td>
<td>—</td>
<td>
None ships a Result. Closest is their empty state — which is why ours shares
EmptyState's skeleton and differs only by contract.
</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>{resultMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{resultMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{resultMorfo.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);">
<code>data-status</code> is an eidos-only wrapper attr (it never crosses layers — banner
precedent), so it stays out of the contract. <code>extra</code> declares no archetype: a
genuinely component-specific part (types.ts sanctions omitting the field).
</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/result/result.css</code>. Public tokens:
<code>--result-gap · actions-gap · description-measure · extra-measure ·
{'{status}'}-color</code> (overridable via <code>recipes.result.*</code>). The status maps
to a role color for the GLYPH only; HTTP codes render neutral mono text.
</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-result]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker — centered column, gap + page-level padding.</td>
</tr>
<tr>
<td class="name"><code>[data-result][data-status='X']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Glyph color per evaluative status (fulfill / threat / risk / primary).</td>
</tr>
<tr>
<td class="name"><code>[data-result-code]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>The big neutral HTTP code (mono, semibold, muted).</td>
</tr>
<tr>
<td class="name"><code>[data-result-media/title/description/actions/extra]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Part styling — display glyph scale, heading, measure, action row, detail.</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 terminal surface, not a live region. The outcome's
perceptual signal belonged to the operation that resolved.
</td>
</tr>
<tr>
<td class="name">SPA navigation</td>
<td>
When a Result renders after client-side navigation, the APP moves focus to the
title (or announces politely) — no reference implements this either; the
pattern is documented here so consumers wire it deliberately.
</td>
</tr>
<tr>
<td class="name">Heading</td>
<td>
Title is a real <code>&lt;h{'{level}'}&gt;</code> (default h2 — the surface
usually IS the page).
</td>
</tr>
<tr>
<td class="name">Media</td>
<td>
Default glyphs are decorative (<code>aria-hidden</code>); the HTTP code is
<code>aria-hidden</code> too — the title says "Page not found", the 404 is
ornament.
</td>
</tr>
<tr>
<td class="name">Color</td>
<td>
Status never travels on color alone: the title text carries the outcome; the
glyph color is reinforcement (HTTP codes stay neutral by doctrine).
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.