feat(timeline): cross-layer Timeline component (history/feed + progress)

Unifies the history/feed pattern (per-item status + evaluative intent) with the
progress/stepper pattern (a root `active` index) in one component. Two
independent color axes: status drives the marker treatment (filled / ring /
hollow); intent overrides the hue via the canonical data-intent channel and
feeds the `emerge-reveal` event in live feeds.

- morfo: <ol>/<li> contract — data-status, data-intent, aria-current; one
  `emerge-reveal` event (fromProp intent); scope soma/sema/eidos.
- soma: Provider + Item — status derivation (active|explicit), auto-index,
  live-feed emission gated to appends. 4 unit tests.
- sema: emerge-reveal pack (soften gain via op:add + haptic tick).
- eidos: rail · markers · connectors; vertical/horizontal + start/end/alternate;
  independent `size` (type) and `markerSize` (circle) axes; connector
  solid/dashed/dotted; live-feed reveal. Markers hold icons or numbers.
- demo: web/routes/uix/components/timeline (6-tab interactive testbed) + nav.

Connector runs behind the markers (continuous rail); horizontal entries stack
via flex; horizontal-alternate pins fixed-height rows for a straight rail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent ec8428218f
commit c1effca1b4

@ -0,0 +1,89 @@
# Timeline (eidos)
The visual layer for `Timeline` — the rail, markers, connectors, orientation and
alternating layout. Behavior (the status model, `active` derivation, the live
feed + `emerge-reveal`) lives in soma: see
[`soma/components/timeline/README.md`](../../../soma/components/timeline/README.md).
```svelte
<script lang="ts">
import { Timeline } from '$uix/eidos/components/timeline';
import { Rocket, CircleCheck } from '$uix/eidos/components/icon';
let active = $state(2);
const events = [/* { time, title, body, icon, intent? } */];
</script>
<Timeline {active} color="primary">
{#each events as ev, i}
{@const Ico = ev.icon}
<Timeline.Item index={i} intent={ev.intent}>
{#snippet children({ status })}
<Timeline.Marker><Ico /></Timeline.Marker>
{#if i < events.length - 1}<Timeline.Connector />{/if}
<Timeline.Time datetime={ev.iso}>{ev.time}</Timeline.Time>
<Timeline.Content>
<Timeline.Title>{ev.title}</Timeline.Title>
<Timeline.Description>{ev.body}</Timeline.Description>
</Timeline.Content>
{/snippet}
</Timeline.Item>
{/each}
</Timeline>
```
## Parts
| Part | Element | Owner | Notes |
| ------------- | -------- | ----- | ---------------------------------------------- |
| `Timeline` | `<ol>` | soma | Root. Status model + live feed. |
| `Item` | `<li>` | soma | Entry. Exposes `{ status }`. |
| `Marker` | `<div>` | eidos | The dot. Holds an icon or number; tinted. |
| `Connector` | `<div>` | eidos | The rail line. `variant` = solid/dashed/dotted. |
| `Time` | `<time>` | eidos | Timestamp / meta opposite the content. |
| `Content` | `<div>` | eidos | Entry body (Title + Description). |
| `Title` | `<h3>` | eidos | Heading (`as` overrides the level). |
| `Description` | `<p>` | eidos | Secondary prose. |
## Visual props (`<Timeline>`)
| Prop | Type | Default | Notes |
| ------------- | ------------------------------------- | ------------ | ----------------------------------------------------------- |
| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout direction (RTL handled via logical properties). |
| `align` | `'start' \| 'end' \| 'alternate'` | `'start'` | Content side relative to the rail. `alternate` zig-zags. |
| `size` | `ResponsiveProp<'sm' \| 'md' \| 'lg'>` | `'md'` | **Type + spacing** density. |
| `markerSize` | `ResponsiveProp<'sm' \| 'md' \| 'lg'>` | `'md'` | **Circle diameter** — independent of `size`. |
| `color` | `ProgressiveColorRole` | `'primary'` | Accent for done / current markers. |
`<Timeline.Connector variant="solid | dashed | dotted">` picks the line style
(default `solid`).
## Two color axes
1. **Status** (`data-status`: done / current / upcoming) drives the marker
**treatment** — done = solid fill, current = ring, upcoming = hollow. The hue
comes from the root `color` accent.
2. **Intent** (the item's `intent`) **overrides the hue** with the canonical
evaluative palette (affirm = green, threat = red, …) via the standard
`data-intent` channel, and also feeds the `emerge-reveal` event in live feeds.
The hue lives in `--_timeline-hue-*` on the item so the marker and the connector
share it.
## Layout notes
- **Vertical**: a two-column grid (rail · body). The connector is an absolutely
positioned logical border running from each marker's centre to the next, behind
the opaque dot — one continuous rail. `align=end` mirrors; `align=alternate`
centres the rail and zig-zags the entries by parity.
- **Horizontal**: each entry is a flex column (marker · time · content) so the
text stacks; the timeline scrolls instead of shrinking. `align=alternate`
pins fixed-height content/time rows so every marker lands on a straight rail —
keep horizontal-alternate content concise (title + one description line).
- **Reduced motion**: the live-feed reveal is disabled under
`prefers-reduced-motion`.
## Recipe
Tokens: `src/uix/eidos/lib/recipes/base.ts` (`timeline`). Selectors:
[`timeline.css`](./timeline.css). Colors are referenced through the canonical
`--color-{role}-*` tokens (no raw values).

@ -0,0 +1,48 @@
import TimelineComponent from './timeline.svelte';
import Item from './timeline-item.svelte';
import Marker from './timeline-marker.svelte';
import Connector from './timeline-connector.svelte';
import Time from './timeline-time.svelte';
import Content from './timeline-content.svelte';
import Title from './timeline-title.svelte';
import Description from './timeline-description.svelte';
type TimelineNamespace = typeof TimelineComponent & {
Item: typeof Item;
Marker: typeof Marker;
Connector: typeof Connector;
Time: typeof Time;
Content: typeof Content;
Title: typeof Title;
Description: typeof Description;
};
const Timeline = TimelineComponent as TimelineNamespace;
Timeline.Item = Item;
Timeline.Marker = Marker;
Timeline.Connector = Connector;
Timeline.Time = Time;
Timeline.Content = Content;
Timeline.Title = Title;
Timeline.Description = Description;
export { Timeline };
export default Timeline;
export type {
TimelineStatus,
TimelineProps,
TimelineItemProps as ItemProps,
TimelineMarkerProps as MarkerProps,
TimelineConnectorProps as ConnectorProps,
TimelineTimeProps as TimeProps,
TimelineContentProps as ContentProps,
TimelineTitleProps as TitleProps,
TimelineDescriptionProps as DescriptionProps,
TimelineSize,
TimelineColor,
TimelineOrientation,
TimelineAlign,
TimelineConnectorVariant
} from './types';

@ -0,0 +1,11 @@
<script lang="ts">
/**
* Eidos `<Timeline.Connector>` — the rail line to the next entry.
* Decorative; `variant` picks the line style.
*/
import type { TimelineConnectorProps } from './types';
let { variant, ...rest }: TimelineConnectorProps = $props();
</script>
<div data-timeline-connector="" data-variant={variant} aria-hidden="true" {...rest}></div>

@ -0,0 +1,10 @@
<script lang="ts">
/** Eidos `<Timeline.Content>` — the entry body (Title + Description). */
import type { TimelineContentProps } from './types';
let { children, ...rest }: TimelineContentProps = $props();
</script>
<div data-timeline-content="" {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,10 @@
<script lang="ts">
/** Eidos `<Timeline.Description>` — secondary prose under the Title. */
import type { TimelineDescriptionProps } from './types';
let { children, ...rest }: TimelineDescriptionProps = $props();
</script>
<p data-timeline-description="" {...rest}>
{@render children?.()}
</p>

@ -0,0 +1,12 @@
<script lang="ts">
import * as Timeline from '$soma/components/timeline';
import type { TimelineStatus, TimelineItemProps } from './types';
let { children: bodyContent, ...rest }: TimelineItemProps = $props();
</script>
<Timeline.Item {...rest}>
{#snippet children(args: { status: TimelineStatus | undefined })}
{@render bodyContent?.(args)}
{/snippet}
</Timeline.Item>

@ -0,0 +1,10 @@
<script lang="ts">
/** Eidos `<Timeline.Marker>` — the decorative dot. Holds an icon or number. */
import type { TimelineMarkerProps } from './types';
let { children, ...rest }: TimelineMarkerProps = $props();
</script>
<div data-timeline-marker="" aria-hidden="true" {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,10 @@
<script lang="ts">
/** Eidos `<Timeline.Time>` — timestamp / meta opposite the content. */
import type { TimelineTimeProps } from './types';
let { children, ...rest }: TimelineTimeProps = $props();
</script>
<time data-timeline-time="" {...rest}>
{@render children?.()}
</time>

@ -0,0 +1,10 @@
<script lang="ts">
/** Eidos `<Timeline.Title>` — entry heading. `<h3>` by default; override via `as`. */
import type { TimelineTitleProps } from './types';
let { as = 'h3', children, ...rest }: TimelineTitleProps = $props();
</script>
<svelte:element this={as} data-timeline-title="" {...rest}>
{@render children?.()}
</svelte:element>

@ -0,0 +1,479 @@
/*
* Timeline — a vertical/horizontal sequence of events.
*
* Two independent color axes:
* - STATUS (data-status: done | current | upcoming) drives the marker
* TREATMENT (filled / ring / hollow). The hue comes from the root accent
* (data-color, default primary).
* - INTENT (data-intent on the item) OVERRIDES the hue with the canonical
* evaluative palette (affirm / threat / …). It wins over the accent.
*
* The hue lives in `--_timeline-hue-*` on the ITEM so both the marker and the
* connector inherit it. The status treatments read it.
*
* Layout: logical properties throughout (RTL-safe). The connector is absolutely
* positioned in the rail track so it bridges the gap to the next entry without a
* wrapper element.
*/
[data-timeline] {
/* size-scaled (default md) */
--_timeline-gap: var(--timeline-gap-md);
--_timeline-item-gap: var(--timeline-item-gap-md);
--_timeline-marker-size: var(--timeline-marker-size-md);
--_timeline-marker-icon-size: var(--timeline-marker-icon-size-md);
--_timeline-font-size: var(--timeline-font-size-md);
--_timeline-time-font-size: var(--timeline-time-font-size-md);
/* status accent — default primary; data-color overrides below */
--_timeline-accent-solid: var(--color-primary-solid);
--_timeline-accent-track: var(--color-primary-track);
--_timeline-accent-border: var(--color-primary-border);
--_timeline-accent-contrast: var(--color-primary-contrast);
--_timeline-accent-text: var(--color-primary-text);
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
min-inline-size: 0;
font-family: var(--timeline-font-family);
font-size: var(--_timeline-font-size);
line-height: var(--timeline-line-height);
color: var(--timeline-color);
}
/* `size` is the TYPE / density axis (letters + spacing). The circle size is a
* SEPARATE axis (`markerSize` → data-marker-size) below, so the dot and the
* text scale independently — both default to md. */
[data-timeline][data-size='sm'] {
--_timeline-gap: var(--timeline-gap-sm);
--_timeline-item-gap: var(--timeline-item-gap-sm);
--_timeline-font-size: var(--timeline-font-size-sm);
--_timeline-time-font-size: var(--timeline-time-font-size-sm);
}
[data-timeline][data-size='lg'] {
--_timeline-gap: var(--timeline-gap-lg);
--_timeline-item-gap: var(--timeline-item-gap-lg);
--_timeline-font-size: var(--timeline-font-size-lg);
--_timeline-time-font-size: var(--timeline-time-font-size-lg);
}
/* Circle (marker) size axis — independent of the type size. */
[data-timeline][data-marker-size='sm'] {
--_timeline-marker-size: var(--timeline-marker-size-sm);
--_timeline-marker-icon-size: var(--timeline-marker-icon-size-sm);
}
[data-timeline][data-marker-size='lg'] {
--_timeline-marker-size: var(--timeline-marker-size-lg);
--_timeline-marker-icon-size: var(--timeline-marker-icon-size-lg);
}
/* ── Status accent by data-color (root) ─────────────────────────────────── */
[data-timeline][data-color='secondary'] {
--_timeline-accent-solid: var(--color-secondary-solid);
--_timeline-accent-track: var(--color-secondary-track);
--_timeline-accent-border: var(--color-secondary-border);
--_timeline-accent-contrast: var(--color-secondary-contrast);
--_timeline-accent-text: var(--color-secondary-text);
}
[data-timeline][data-color='neutral'] {
--_timeline-accent-solid: var(--color-neutral-solid);
--_timeline-accent-track: var(--color-neutral-track);
--_timeline-accent-border: var(--color-neutral-border);
--_timeline-accent-contrast: var(--color-neutral-contrast);
--_timeline-accent-text: var(--color-neutral-text);
}
[data-timeline][data-color='affirm'] {
--_timeline-accent-solid: var(--color-affirm-solid);
--_timeline-accent-track: var(--color-affirm-track);
--_timeline-accent-border: var(--color-affirm-border);
--_timeline-accent-contrast: var(--color-affirm-contrast);
--_timeline-accent-text: var(--color-affirm-text);
}
[data-timeline][data-color='fulfill'] {
--_timeline-accent-solid: var(--color-fulfill-solid);
--_timeline-accent-track: var(--color-fulfill-track);
--_timeline-accent-border: var(--color-fulfill-border);
--_timeline-accent-contrast: var(--color-fulfill-contrast);
--_timeline-accent-text: var(--color-fulfill-text);
}
/* ── Item: owns the hue (accent by default, intent overrides) ───────────── */
[data-timeline-item] {
--_timeline-hue-solid: var(--_timeline-accent-solid);
--_timeline-hue-track: var(--_timeline-accent-track);
--_timeline-hue-border: var(--_timeline-accent-border);
--_timeline-hue-contrast: var(--_timeline-accent-contrast);
--_timeline-hue-text: var(--_timeline-accent-text);
position: relative;
min-inline-size: 0;
}
[data-timeline-item][data-intent='neutral'] {
--_timeline-hue-solid: var(--color-neutral-solid);
--_timeline-hue-track: var(--color-neutral-track);
--_timeline-hue-border: var(--color-neutral-border);
--_timeline-hue-contrast: var(--color-neutral-contrast);
--_timeline-hue-text: var(--color-neutral-text);
}
[data-timeline-item][data-intent='affirm'] {
--_timeline-hue-solid: var(--color-affirm-solid);
--_timeline-hue-track: var(--color-affirm-track);
--_timeline-hue-border: var(--color-affirm-border);
--_timeline-hue-contrast: var(--color-affirm-contrast);
--_timeline-hue-text: var(--color-affirm-text);
}
[data-timeline-item][data-intent='fulfill'] {
--_timeline-hue-solid: var(--color-fulfill-solid);
--_timeline-hue-track: var(--color-fulfill-track);
--_timeline-hue-border: var(--color-fulfill-border);
--_timeline-hue-contrast: var(--color-fulfill-contrast);
--_timeline-hue-text: var(--color-fulfill-text);
}
[data-timeline-item][data-intent='risk'] {
--_timeline-hue-solid: var(--color-risk-solid);
--_timeline-hue-track: var(--color-risk-track);
--_timeline-hue-border: var(--color-risk-border);
--_timeline-hue-contrast: var(--color-risk-contrast);
--_timeline-hue-text: var(--color-risk-text);
}
[data-timeline-item][data-intent='threat'] {
--_timeline-hue-solid: var(--color-threat-solid);
--_timeline-hue-track: var(--color-threat-track);
--_timeline-hue-border: var(--color-threat-border);
--_timeline-hue-contrast: var(--color-threat-contrast);
--_timeline-hue-text: var(--color-threat-text);
}
[data-timeline-item][data-intent='loss'] {
--_timeline-hue-solid: var(--color-loss-solid);
--_timeline-hue-track: var(--color-loss-track);
--_timeline-hue-border: var(--color-loss-border);
--_timeline-hue-contrast: var(--color-loss-contrast);
--_timeline-hue-text: var(--color-loss-text);
}
/* ── Vertical layout (default) ──────────────────────────────────────────── */
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-item] {
display: grid;
grid-template-columns: var(--_timeline-marker-size) minmax(0, 1fr);
column-gap: var(--_timeline-gap);
align-items: start;
padding-block-end: var(--_timeline-item-gap);
}
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-marker] {
grid-column: 1;
grid-row: 1;
}
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-time],
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-content] {
grid-column: 2;
}
/* Connector: a line in the rail track, from below the marker to the item's
* bottom edge (padding included) so it reaches the next entry. Rendered as a
* logical border so `solid | dashed | dotted` come for free and RTL is handled
* by the writing direction (no transform flip needed). */
[data-timeline]:not([data-orientation='horizontal']) [data-timeline-connector] {
position: absolute;
/* From this marker's CENTER down to the item's bottom edge (= the next
* marker's top). The line runs BEHIND the opaque marker (z-index), so it
* reads as one continuous rail through the dots. */
inset-block: calc(var(--_timeline-marker-size) / 2) 0;
inset-inline-start: calc((var(--_timeline-marker-size) - var(--timeline-connector-thickness)) / 2);
inline-size: 0;
border-inline-start: var(--timeline-connector-thickness) var(--_timeline-connector-style)
var(--timeline-connector-color);
min-block-size: var(--timeline-connector-min-size);
}
/* align=end — rail on the inline-end side */
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-item] {
grid-template-columns: minmax(0, 1fr) var(--_timeline-marker-size);
}
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-marker] {
grid-column: 2;
}
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-time],
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-content] {
grid-column: 1;
text-align: end;
}
[data-timeline]:not([data-orientation='horizontal'])[data-align='end'] [data-timeline-connector] {
inset-inline: auto calc((var(--_timeline-marker-size) - var(--timeline-connector-thickness)) / 2);
}
/* align=alternate — rail in the center, entries zig-zag by parity */
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate'] [data-timeline-item] {
grid-template-columns: minmax(0, 1fr) var(--_timeline-marker-size) minmax(0, 1fr);
}
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate'] [data-timeline-marker] {
grid-column: 2;
}
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate'] [data-timeline-connector] {
inset-inline-start: calc(50% - var(--timeline-connector-thickness) / 2);
}
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate']
[data-timeline-item]:nth-child(odd)
[data-timeline-content],
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate']
[data-timeline-item]:nth-child(odd)
[data-timeline-time] {
grid-column: 3;
text-align: start;
}
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate']
[data-timeline-item]:nth-child(even)
[data-timeline-content],
[data-timeline]:not([data-orientation='horizontal'])[data-align='alternate']
[data-timeline-item]:nth-child(even)
[data-timeline-time] {
grid-column: 1;
text-align: end;
}
/* ── Horizontal layout ──────────────────────────────────────────────────── */
[data-timeline][data-orientation='horizontal'] {
flex-direction: row;
align-items: start;
overflow-x: auto;
}
[data-timeline][data-orientation='horizontal'] [data-timeline-item] {
/* Each entry stacks vertically (marker · time · content); the absolute
* connector overlays the marker row. Flex (not grid) so time and content
* FLOW one below the other — two grid items in one cell would overlap. */
display: flex;
flex-direction: column;
align-items: start;
gap: var(--_timeline-gap);
padding-inline-end: var(--_timeline-item-gap);
/* Don't shrink to fit the rail — keep a comfortable width and let the
* timeline scroll, so the entry text never collapses to one word per line. */
flex-shrink: 0;
min-inline-size: var(--timeline-item-inline);
}
[data-timeline][data-orientation='horizontal'] [data-timeline-connector] {
position: absolute;
/* From this marker's CENTER across to the item's inline-end edge (= the next
* marker's start), behind the dots. */
inset-inline: calc(var(--_timeline-marker-size) / 2) 0;
inset-block-start: calc((var(--_timeline-marker-size) - var(--timeline-connector-thickness)) / 2);
block-size: 0;
border-block-start: var(--timeline-connector-thickness) var(--_timeline-connector-style)
var(--timeline-connector-color);
min-inline-size: var(--timeline-connector-min-size);
}
/* horizontal align=end — content above the rail (marker drops to the bottom) */
[data-timeline][data-orientation='horizontal'][data-align='end'] [data-timeline-item] {
flex-direction: column-reverse;
}
[data-timeline][data-orientation='horizontal'][data-align='end'] [data-timeline-connector] {
inset-block: auto calc((var(--_timeline-marker-size) - var(--timeline-connector-thickness)) / 2);
}
/* horizontal align=alternate — rail centered, content zig-zags above/below.
* The `1fr auto 1fr` rows keep the marker at the item's vertical centre for
* every entry, so a single connector at 50% lines up across all of them
* regardless of content height (no fixed-height assumption). */
[data-timeline][data-orientation='horizontal'][data-align='alternate'] [data-timeline-item] {
display: grid;
grid-template-columns: minmax(0, 1fr);
/* even-content · even-time · marker · odd-time · odd-content. Rows are PINNED
* (fixed heights) so every marker lands at the same offset — a straight rail
* — and so time + content land in SEPARATE rows (two grid items in one cell
* would overlap). The time always sits nearest the rail. */
grid-template-rows:
var(--timeline-alt-content-block)
var(--timeline-alt-time-block)
auto
var(--timeline-alt-time-block)
var(--timeline-alt-content-block);
justify-items: start;
align-items: center;
}
[data-timeline][data-orientation='horizontal'][data-align='alternate'] [data-timeline-marker] {
grid-row: 3;
}
[data-timeline][data-orientation='horizontal'][data-align='alternate']
[data-timeline-item]:nth-child(odd)
[data-timeline-time] {
grid-row: 4;
align-self: start;
}
[data-timeline][data-orientation='horizontal'][data-align='alternate']
[data-timeline-item]:nth-child(odd)
[data-timeline-content] {
grid-row: 5;
align-self: start;
}
[data-timeline][data-orientation='horizontal'][data-align='alternate']
[data-timeline-item]:nth-child(even)
[data-timeline-time] {
grid-row: 2;
align-self: end;
}
[data-timeline][data-orientation='horizontal'][data-align='alternate']
[data-timeline-item]:nth-child(even)
[data-timeline-content] {
grid-row: 1;
align-self: end;
}
[data-timeline][data-orientation='horizontal'][data-align='alternate'] [data-timeline-connector] {
inset-block: 50% auto;
transform: translateY(-50%);
}
/* Hide a stray connector on the last entry. */
[data-timeline-item]:last-child [data-timeline-connector] {
display: none;
}
/* ── Marker ─────────────────────────────────────────────────────────────── */
[data-timeline-marker] {
/* Scale any <Icon> inside the marker with the circle size (the Icon reads
* `--icon-size`), and numbers/text via font-size. */
--icon-size: var(--_timeline-marker-icon-size);
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_timeline-marker-size);
block-size: var(--_timeline-marker-size);
border: var(--timeline-marker-border-width) solid var(--timeline-marker-border);
border-radius: var(--timeline-marker-radius);
background: var(--timeline-marker-bg);
color: var(--timeline-marker-color);
font-weight: var(--timeline-marker-font-weight);
font-size: var(--_timeline-marker-icon-size);
line-height: 1;
transition:
background var(--timeline-transition-duration) var(--timeline-transition-ease),
border-color var(--timeline-transition-duration) var(--timeline-transition-ease),
color var(--timeline-transition-duration) var(--timeline-transition-ease),
box-shadow var(--timeline-transition-duration) var(--timeline-transition-ease);
}
[data-timeline-marker] :global(svg) {
inline-size: var(--_timeline-marker-icon-size);
block-size: var(--_timeline-marker-icon-size);
}
/* Intent present (no status) → solid fill (the feed event's color). */
[data-timeline-item][data-intent] [data-timeline-marker] {
background: var(--_timeline-hue-solid);
border-color: var(--_timeline-hue-solid);
color: var(--_timeline-hue-contrast);
}
/* Status treatments (read --_timeline-hue-*, set by accent or intent). Source
* order after the intent-fill rule so done/current/upcoming win. */
[data-timeline-item][data-status='done'] [data-timeline-marker] {
background: var(--_timeline-hue-solid);
border-color: var(--_timeline-hue-solid);
color: var(--_timeline-hue-contrast);
}
[data-timeline-item][data-status='current'] [data-timeline-marker] {
background: var(--_timeline-hue-track);
border-color: var(--_timeline-hue-border);
color: var(--_timeline-hue-text);
box-shadow: 0 0 0 var(--timeline-marker-ring-width) var(--_timeline-hue-track);
}
[data-timeline-item][data-status='upcoming'] [data-timeline-marker] {
background: var(--timeline-marker-bg);
border-color: var(--timeline-marker-border);
color: var(--timeline-marker-color);
box-shadow: none;
}
/* ── Connector ──────────────────────────────────────────────────────────── */
[data-timeline-connector] {
--_timeline-connector-style: solid;
}
[data-timeline-connector][data-variant='dashed'] {
--_timeline-connector-style: dashed;
}
[data-timeline-connector][data-variant='dotted'] {
--_timeline-connector-style: dotted;
}
/* A completed segment (or any tinted feed entry) colors the line. */
[data-timeline-item][data-status='done'] [data-timeline-connector],
[data-timeline-item][data-intent] [data-timeline-connector] {
border-color: var(--_timeline-hue-border);
}
/* ── Time / Content / Title / Description ───────────────────────────────── */
[data-timeline-time] {
font-size: var(--_timeline-time-font-size);
font-weight: var(--timeline-time-font-weight);
color: var(--timeline-time-color);
white-space: nowrap;
}
[data-timeline-content] {
display: flex;
flex-direction: column;
gap: var(--timeline-content-gap);
min-inline-size: 0;
}
[data-timeline-title] {
margin: 0;
font-size: 1em;
font-weight: var(--timeline-title-font-weight);
color: var(--timeline-title-color);
line-height: var(--timeline-line-height);
}
[data-timeline-description] {
margin: 0;
font-size: 1em;
color: var(--timeline-description-color);
line-height: var(--timeline-line-height);
}
/* ── Live-feed reveal ───────────────────────────────────────────────────── */
[data-timeline][data-live] [data-timeline-item] {
animation: timeline-reveal var(--timeline-reveal-duration) var(--timeline-reveal-ease) both;
}
@keyframes timeline-reveal {
from {
opacity: 0;
transform: translateY(var(--timeline-reveal-distance));
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Reduced motion ─────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
[data-timeline-marker] {
transition: none;
}
[data-timeline-item][data-status='current'] [data-timeline-marker] {
animation: none;
}
[data-timeline][data-live] [data-timeline-item] {
animation: none;
}
}

@ -0,0 +1,31 @@
<script lang="ts">
import './timeline.css';
import { ActiveEidos } from '$uix/eidos';
import * as Timeline from '$soma/components/timeline';
import type { TimelineProps } from './types';
let {
size = 'md',
markerSize = 'md',
color = 'primary',
orientation = 'vertical',
align = 'start',
children: bodyContent,
...rest
}: TimelineProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedMarkerSize = $derived(eidos.resolve(markerSize, 'md'));
</script>
<Timeline.Provider
{...rest}
data-size={resolvedSize}
data-marker-size={resolvedMarkerSize}
data-color={color}
data-orientation={orientation}
data-align={align}
>
{@render bodyContent?.()}
</Timeline.Provider>

@ -0,0 +1,58 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes, HTMLTimeAttributes } from 'svelte/elements';
import type { ProgressiveColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { ItemProps, ProviderProps, TimelineStatus } from '$soma/components/timeline';
/** Visual density. Timeline is a composite panel — `sm · md · lg`. */
export type TimelineSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/** Status accent (done / current). Progressive roles, like Stepper. */
export type TimelineColor = ProgressiveColorRole;
export type TimelineOrientation = 'vertical' | 'horizontal';
/** Which side of the rail the content sits on. `alternate` zig-zags by parity. */
export type TimelineAlign = 'start' | 'end' | 'alternate';
/** Connector line style. */
export type TimelineConnectorVariant = 'solid' | 'dashed' | 'dotted';
export type TimelineProps = ProviderProps & {
/** Type + spacing density (letters, gaps). Independent of the circle size. @default 'md' */
size?: ResponsiveProp<TimelineSize>;
/** Circle (marker) diameter. Independent of the type size. @default 'md' */
markerSize?: ResponsiveProp<TimelineSize>;
/** Accent palette for done / current markers. @default 'primary' */
color?: TimelineColor;
/** Layout direction of the sequence. @default 'vertical' */
orientation?: TimelineOrientation;
/** Content side relative to the rail. @default 'start' */
align?: TimelineAlign;
};
export type TimelineItemProps = ItemProps;
export type TimelineMarkerProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
children?: Snippet;
};
export type TimelineConnectorProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** Line style. @default 'solid' */
variant?: TimelineConnectorVariant;
};
export type TimelineTimeProps = Omit<HTMLTimeAttributes, 'children'> & {
children?: Snippet;
};
export type TimelineContentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
children?: Snippet;
};
export type TimelineTitleProps = Omit<HTMLAttributes<HTMLHeadingElement>, 'children'> & {
/** Heading level override. @default 'h3' */
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
children?: Snippet;
};
export type TimelineDescriptionProps = Omit<HTMLAttributes<HTMLParagraphElement>, 'children'> & {
children?: Snippet;
};
export type { TimelineStatus };

@ -2792,6 +2792,51 @@
--stepper-neutral-border: var(--color-neutral-border);
--stepper-affirm-track: var(--color-affirm-track);
--stepper-affirm-border: var(--color-affirm-border);
--timeline-gap-sm: var(--space-3);
--timeline-gap-md: var(--space-4);
--timeline-gap-lg: var(--space-5);
--timeline-item-gap-sm: var(--space-5);
--timeline-item-gap-md: var(--space-6);
--timeline-item-gap-lg: var(--space-7);
--timeline-marker-size-sm: var(--control-height-sm);
--timeline-marker-size-md: var(--control-height-md);
--timeline-marker-size-lg: var(--control-height-lg);
--timeline-marker-icon-size-sm: var(--icon-size-sm);
--timeline-marker-icon-size-md: var(--icon-size-md);
--timeline-marker-icon-size-lg: var(--icon-size-lg);
--timeline-font-family: var(--font-ui);
--timeline-font-size-sm: var(--font-size-sm);
--timeline-font-size-md: var(--font-size-md);
--timeline-font-size-lg: var(--font-size-lg);
--timeline-time-font-size-sm: var(--font-size-xs);
--timeline-time-font-size-md: var(--font-size-sm);
--timeline-time-font-size-lg: var(--font-size-md);
--timeline-line-height: var(--leading-ui);
--timeline-color: var(--color-content-primary);
--timeline-marker-radius: var(--radius-full);
--timeline-marker-border-width: var(--border-width-medium);
--timeline-marker-border: var(--color-border-default);
--timeline-marker-bg: var(--color-surface-default);
--timeline-marker-color: var(--color-content-secondary);
--timeline-marker-font-weight: var(--font-weight-semibold);
--timeline-marker-ring-width: var(--space-1);
--timeline-connector-thickness: var(--border-width-medium);
--timeline-connector-color: var(--color-border-subtle);
--timeline-connector-min-size: var(--space-6);
--timeline-item-inline: 10rem;
--timeline-alt-content-block: 3.25rem;
--timeline-alt-time-block: 1.5rem;
--timeline-content-gap: var(--space-1);
--timeline-time-font-weight: var(--font-weight-medium);
--timeline-time-color: var(--color-content-secondary);
--timeline-title-font-weight: var(--font-weight-semibold);
--timeline-title-color: var(--color-content-primary);
--timeline-description-color: var(--color-content-secondary);
--timeline-reveal-duration: var(--duration-moderate);
--timeline-reveal-ease: var(--ease-out);
--timeline-reveal-distance: var(--space-3);
--timeline-transition-duration: var(--duration-fast);
--timeline-transition-ease: var(--ease-default);
--slider-track-size-xs: 3px;
--slider-track-size-sm: 4px;
--slider-track-size-md: 6px;

@ -3216,6 +3216,66 @@ export const THEME_BASE_RECIPE_TOKENS = {
]
}
},
timeline: {
// ── Rhythm (size-scaled) ──
'gap-sm': 'var(--space-3)',
'gap-md': 'var(--space-4)',
'gap-lg': 'var(--space-5)',
'item-gap-sm': 'var(--space-5)',
'item-gap-md': 'var(--space-6)',
'item-gap-lg': 'var(--space-7)',
'marker-size-sm': 'var(--control-height-sm)',
'marker-size-md': 'var(--control-height-md)',
'marker-size-lg': 'var(--control-height-lg)',
'marker-icon-size-sm': 'var(--icon-size-sm)',
'marker-icon-size-md': 'var(--icon-size-md)',
'marker-icon-size-lg': 'var(--icon-size-lg)',
'font-family': 'var(--font-ui)',
'font-size-sm': 'var(--font-size-sm)',
'font-size-md': 'var(--font-size-md)',
'font-size-lg': 'var(--font-size-lg)',
// Time meta sits one step below the item's font size (field-label rhythm).
'time-font-size-sm': 'var(--font-size-xs)',
'time-font-size-md': 'var(--font-size-sm)',
'time-font-size-lg': 'var(--font-size-md)',
'line-height': 'var(--leading-ui)',
color: 'var(--color-content-primary)',
// ── Marker (idle / default treatment) ──
'marker-radius': 'var(--radius-full)',
'marker-border-width': 'var(--border-width-medium)',
'marker-border': 'var(--color-border-default)',
'marker-bg': 'var(--color-surface-default)',
'marker-color': 'var(--color-content-secondary)',
'marker-font-weight': 'var(--font-weight-semibold)',
'marker-ring-width': 'var(--space-1)',
// ── Connector ──
'connector-thickness': 'var(--border-width-medium)',
'connector-color': 'var(--color-border-subtle)',
'connector-min-size': 'var(--space-6)',
// Comfortable width for a horizontal entry so its text doesn't collapse
// when the rail overflows (the timeline scrolls instead of shrinking).
'item-inline': '10rem',
// Fixed region heights for horizontal `align=alternate`: the content and
// time rows are pinned so the marker lands at a constant offset and the
// rail stays straight across entries. Keep horizontal-alternate content
// concise (title + one description line).
'alt-content-block': '3.25rem',
'alt-time-block': '1.5rem',
// ── Content / time / title / description ──
'content-gap': 'var(--space-1)',
'time-font-weight': 'var(--font-weight-medium)',
'time-color': 'var(--color-content-secondary)',
'title-font-weight': 'var(--font-weight-semibold)',
'title-color': 'var(--color-content-primary)',
'description-color': 'var(--color-content-secondary)',
// ── Live-feed reveal ──
'reveal-duration': 'var(--duration-moderate)',
'reveal-ease': 'var(--ease-out)',
'reveal-distance': 'var(--space-3)',
// ── Transitions ──
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)'
},
slider: {
'track-size-xs': '3px',
'track-size-sm': '4px',
@ -4255,9 +4315,11 @@ export const THEME_BASE_RECIPE_TOKENS = {
// (generated to `--fab-*`), not as literals in fab.css.
// ─────────────────────────────────────────────────────────────────────
fab: {
'size-xs': '1.875rem',
'size-sm': '2.5rem',
'size-md': '3.5rem',
'size-lg': '4.5rem',
'icon-xs': '1rem',
'icon-sm': '1.25rem',
'icon-md': '1.5rem',
'icon-lg': '1.75rem',

@ -73,6 +73,7 @@ import { timeFieldLangs } from './time-field';
import { timePickerLangs } from './time-picker';
import { timeRangeFieldLangs } from './time-range-field';
import { timeRangePickerLangs } from './time-range-picker';
import { timelineLangs } from './timeline';
import { toastLangs } from './toast';
import { toggleLangs } from './toggle';
import { toggleGroupLangs } from './toggle-group';
@ -164,6 +165,7 @@ export const componentLangs = {
'time-picker': timePickerLangs,
'time-range-field': timeRangeFieldLangs,
'time-range-picker': timeRangePickerLangs,
timeline: timelineLangs,
toast: toastLangs,
toggle: toggleLangs,
'toggle-group': toggleGroupLangs,

@ -0,0 +1,15 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the timeline component. Merged under
* `components.timeline.*` by `ActiveUix` (via the `componentLangs` barrel).
*
* Morfo references entries by absolute idlangref:
* `'#?components.timeline.{key}|fallback'`.
*/
export const timelineLangs = {
label: {
es: 'Cronología',
en: 'Timeline'
}
} satisfies LangNode;

@ -0,0 +1,190 @@
import type { Morfo } from '../types';
import { v } from '../types';
import { INTENTS } from '../../intent';
/**
* Timeline — a vertical/horizontal sequence of events.
*
* Unifies two patterns reference libraries keep apart:
*
* - **History / feed** — each item carries its own `status`
* (done / current / upcoming) and an optional evaluative `intent`
* (affirm / threat / …) that tints its marker via the canonical
* `data-intent` color channel (the same surface Banner / Toast read).
* - **Progress (stepper-like)** — a root-level `active` index derives
* each item's status by position, so the same component renders a
* read-only vertical stepper without re-declaring per-item status.
*
* Display-only by design: the `<ol>` / `<li>` structure carries the
* semantics, `aria-current="step"` marks the active item, the marker and
* connector are decorative (`aria-hidden`). No roving tabindex / keyboard —
* interactive step-jumping stays with `Stepper`.
*
* Layer split: the morfo declares only the **behavioral / semantic**
* surface — `data-status` (soma derives it from `active`/`status`),
* `data-intent` (feeds the event), `aria-current`, `data-live`. The
* **visual** axes (`orientation`, `align`, `size`, `color`, connector
* `variant`) are eidos-only attrs stamped by the wrappers and reached on
* the children through descendant selectors — exactly like `data-size` /
* `data-variant` on the other recipes.
*
* One semantic event: `emerge-reveal` — fired by an Item when it mounts
* into a `live` feed, so a real activity stream announces new entries
* (intent comes from the item's `intent` prop). Static timelines stay
* silent; the initial batch never fires (only later appends do).
*/
export const timelineMorfo = {
name: 'Timeline',
kebab: 'timeline',
scope: ['soma', 'sema', 'eidos'],
expression: 'pack',
texts: {
label: '#?components.timeline.label|Timeline'
},
events: [
{
name: 'emerge-reveal',
semantic: {
family: 'emerge',
verb: 'reveal',
target: v.partRef('item'),
// 'post' — the item is already in the DOM; the reveal signal
// accompanies its settled appearance, it does not precede it.
sequence: 'post',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: [...INTENTS]
}
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'ol',
role: 'list',
optional: false,
data: [
{
attr: 'data-live',
value: v.propRef('live'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'live' }
}
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.timeline.label|Timeline'),
severity: 'recommended'
}
]
},
{
// No `archetype: 'item'` — that archetype is for selectable menu /
// listbox / option rows (it ships cursor:pointer, user-select:none,
// internal padding + a hover highlight). A timeline entry is a
// display `<li>`, structurally unique, so it omits the archetype.
name: 'Item',
kebab: 'item',
kind: 'public',
defaultElement: 'li',
optional: false,
states: ['done', 'current', 'upcoming'],
data: [
{
attr: 'data-status',
values: ['done', 'current', 'upcoming'],
value: v.propRef('status'),
severity: 'optional'
},
{
/**
* Evaluative tint for the item's marker — the canonical UIX
* color-role channel (same one Banner / Toast read). When set,
* it also drives the `intent` of the `emerge-reveal` event.
*/
attr: 'data-intent',
values: [...INTENTS],
value: v.propRef('intent'),
severity: 'optional'
}
],
aria: [
{
attr: 'aria-current',
value: v.literal('step'),
severity: 'optional',
condition: { when: 'state-equals', state: 'current', value: 'true' }
}
]
},
{
name: 'Marker',
kebab: 'marker',
archetype: 'indicator',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
name: 'Connector',
kebab: 'connector',
archetype: 'separator',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
name: 'Time',
kebab: 'time',
archetype: 'label',
kind: 'public',
defaultElement: 'time',
optional: true,
data: [],
aria: []
},
{
name: 'Content',
kebab: 'content',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Rendered as `<h3>` by default in eidos (the `as` prop overrides);
// MorfoElement has no heading levels, so declare `div` + role heading.
name: 'Title',
kebab: 'title',
archetype: 'title',
kind: 'public',
defaultElement: 'div',
role: 'heading',
optional: true,
data: [],
aria: []
},
{
name: 'Description',
kebab: 'description',
archetype: 'description',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -46,6 +46,7 @@ export { tabsSema } from './tabs';
export { tagGroupSema } from './tag-group';
export { tagsInputSema } from './tags-input';
export { textareaSema } from './textarea';
export { timelineSema } from './timeline';
export { toastSema } from './toast';
export { toggleSema } from './toggle';
export { toggleGroupSema } from './toggle-group';

@ -0,0 +1,36 @@
import { semaSelector } from '$uix/morfo';
import { timelineMorfo } from '$uix/morfo/components/timeline';
import type { Sema } from '../sema-map';
/**
* Timeline perceptual defaults — SOUND + HAPTIC ONLY.
*
* Visual concerns (the rail, marker tint by `data-status` / `data-intent`,
* connector line style, the live-feed reveal animation) live in
* `src/uix/eidos/components/timeline/timeline.css`.
*
* The single event is `emerge-reveal`, fired by an Item as it appends into a
* live feed. Its `intent` comes from the item (affirm = a good event landed,
* threat = a failure surfaced, …) so intent.deltas (capa 2) already shape the
* valence. This pack only adds CHARACTER:
*
* - sound: soften the family emerge gain via `op: 'add'` so a feed entry is a
* gentle cue rather than a popover-loud "puff". `op: 'add'` COMPOSES on top
* of the intent deltas — never replace `pitch` / `gain` / `contour`, which
* would flatten the affirm / threat / loss difference (per doctrine).
* - haptic: a light tick — emerge carries no haptic by default.
*/
const onItem = (matchers?: Parameters<typeof semaSelector<typeof timelineMorfo>>[2]) =>
semaSelector(timelineMorfo, 'item', matchers);
export const timelineSema: Sema = {
name: 'timeline',
cascade: [
{
selector: onItem({ eventName: 'emerge-reveal' }),
sound: { gain: { op: 'add', value: -0.08 } },
haptic: { kind: 'tick' }
}
]
};

@ -61,6 +61,7 @@ export * as TagGroup from './tag-group';
export * as TagsInput from './tags-input';
export * as Textarea from './textarea';
export * as TimeField from './time-field';
export * as Timeline from './timeline';
export * as TimePicker from './time-picker';
export * as TimeRangeField from './time-range-field';
export * as TimeRangePicker from './time-range-picker';

@ -0,0 +1,134 @@
# Timeline
A vertical/horizontal sequence of events. Unifies the **history / feed** pattern
(each item carries its own `status` + evaluative `intent`) with the **progress /
stepper** pattern (a root `active` index derives status by position). Display-only:
`<ol>` / `<li>` semantics, `aria-current="step"` on the active item, decorative
markers and connectors. Interactive step-jumping stays with `Stepper`.
## Anatomy
```svelte
<Timeline active={1}>
<Timeline.Item index={0} intent="affirm">
<Timeline.Marker><Check /></Timeline.Marker>
<Timeline.Connector />
<Timeline.Time datetime="2026-06-23T09:30">09:30</Timeline.Time>
<Timeline.Content>
<Timeline.Title>Deploy ok</Timeline.Title>
<Timeline.Description>v2.4.1 to production</Timeline.Description>
</Timeline.Content>
</Timeline.Item>
<Timeline.Item index={1}>
<Timeline.Marker>2</Timeline.Marker>
<Timeline.Content>
<Timeline.Title>Running migrations</Timeline.Title>
</Timeline.Content>
</Timeline.Item>
</Timeline>
```
> `Marker`, `Connector`, `Time`, `Content`, `Title` and `Description` are
> **eidos-only** parts (pure structural display). The soma layer ships the two
> parts that carry behaviour: `Provider` and `Item`.
## Parts (soma)
| Part | Element | Description |
| ---------- | ------- | --------------------------------------------------------------------------- |
| `Provider` | `<ol>` | Root list. Tracks `active`, auto-indexes items, gates the live feed. |
| `Item` | `<li>` | One entry. Derives its `status`, exposes `{ status }`, announces in a feed. |
## Props
### Provider
| Prop | Type | Default | Notes |
| ----------- | ------------------- | ------- | ---------------------------------------------------------------------- |
| `active` | `number \| null` | `null` | Derives each item's status by position. Per-item `status` overrides. |
| `live` | `boolean` | `false` | Feed mode — appended items fire `emerge-reveal` (initial batch silent). |
| `ariaLabel` | `string` | — | Accessible name. Falls back to the translated "Timeline". |
### Item
| Prop | Type | Default | Notes |
| -------- | ----------------------------------- | ------- | ---------------------------------------------------------------- |
| `index` | `number` | auto | Position (0-based). Auto by order; pass it for dynamic lists. |
| `status` | `'done' \| 'current' \| 'upcoming'` | derived | Explicit status — overrides the `active`-derived value. |
| `intent` | `Intent` | — | Marker tint (`data-intent`) + `emerge-reveal` intent in feeds. |
## ARIA
| Part | Attribute | Value |
| -------- | -------------- | --------------------------- |
| Provider | `role` | `list` |
| Provider | `aria-label` | translated "Timeline" |
| Item | `aria-current` | `step` (when current) |
| Marker | `aria-hidden` | `true` (decorative) |
| Connector| `aria-hidden` | `true` (decorative) |
## Data attributes
| Part | Attribute | Values |
| -------- | --------------------- | ----------------------------------- |
| Provider | `data-timeline` | always present |
| Provider | `data-live` | present in feed mode |
| Item | `data-timeline-item` | always present |
| Item | `data-status` | `done` \| `current` \| `upcoming` |
| Item | `data-intent` | `neutral`…`loss` (when set) |
The visual axes — `data-orientation`, `data-align`, `data-size`, `data-color`
(root) and the connector's `data-variant` — are **eidos-only** attrs stamped by
the eidos wrappers; the children read them through descendant selectors.
## Behaviour
### Two color axes
1. **Status** (`done` / `current` / `upcoming`) — the progress axis. Either set
per-item `status` (feed) or let the root `active` derive it (stepper). The
root `color` (eidos) is the accent for done/current.
2. **Intent** (per item) — the semantic axis. `intent` tints the marker via the
canonical `data-intent` channel (affirm = green, threat = red, …) and feeds
the `emerge-reveal` event in live feeds.
### Live feed
With `live`, an item mounting **after** the initial render fires `emerge-reveal`
(intent from the item). The initial batch never fires, so loading a feed of 20
entries does not burst 20 signals — only genuinely new entries announce.
## Sema events
| Event | Family | Verb | Target | Intent | When |
| --------------- | -------- | ------ | ------ | ---------------- | ------------------------------------- |
| `emerge-reveal` | `emerge` | reveal | item | fromProp `intent`| an item appends into a `live` feed |
Pack: `src/uix/sema/components/timeline.ts` — `emerge.soft` tuning + haptic.
## Comparison with reference libraries
| Feature | MUI | Ant Design | PrimeReact | Vuetify | Mantine | Chakra v3 | Soma Timeline |
| -------------------------------- | --- | ---------- | ---------- | ------- | ------- | --------- | ------------- |
| Compositional API | ✅ | ✅+items | data-driven| ✅ | ✅ | ✅ | ✅ |
| Vertical + Horizontal | V | V | ✅ | ✅ | V | V | ✅ |
| Alternating sides / opposite | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| Progress (status + active) | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ (unified) |
| Semantic marker by **intent** | tint| tint | tint | tint | tint | tint | ✅ (canon) |
| Connector dashed / dotted | ❌ | ❌ | partial | partial | ✅ | ✅ | ✅ |
| Live feed (motion + sema) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| RTL (logical) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| `data-*` state per part | partial | ❌ | ❌ | ❌ | ❌ | partial | ✅ |
No reference library unifies progress + feed, ties the marker to a **canonical
intent system**, or ships a live feed with motion + perceptual signals.
## Out of scope (v2 roadmap)
- **`pending` / loading item state** (Ant Design) — a `sustain`-family status with
a spinner marker. Deferred: needs a `sustain` event + spinner composition. ~40 lines.
- **Reverse order prop** (Ant Design) — the consumer reverses the array today; a
`reverse` prop is sugar. Deferred. ~10 lines.
- **Clickable step-jump** — `Stepper` already covers interactive wizards
(tablist/tab + roving keyboard). Timeline stays display-only on purpose.

@ -0,0 +1,41 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { TimelineItemProvider } from '../timeline-provider.svelte';
import type { TimelineItemProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'timeline-item'),
index,
status,
intent,
children,
child,
...restProps
}: TimelineItemProps = $props();
const state = TimelineItemProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
index: readableActive(() => index),
status: readableActive(() => status),
intent: readableActive(() => intent)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ status: state.effectiveStatus, props: mergedProps })}
{:else}
<li {...mergedProps}>
{@render children?.({ status: state.effectiveStatus })}
</li>
{/if}

@ -0,0 +1,41 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { TimelineProvider } from '../timeline-provider.svelte';
import type { TimelineProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'timeline'),
active = null,
live = false,
ariaLabel,
children,
child,
...restProps
}: TimelineProps = $props();
const state = TimelineProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
active: readableActive(() => active),
live: readableActive(() => live),
ariaLabel: readableActive(() => ariaLabel)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<ol {...mergedProps}>
{@render children?.()}
</ol>
{/if}

@ -0,0 +1,8 @@
export { default as Provider } from './components/timeline.svelte';
export { default as Item } from './components/timeline-item.svelte';
export type {
TimelineStatus,
TimelineProps as ProviderProps,
TimelineItemProps as ItemProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,8 @@
/**
* Imperative idlangref constants for the timeline provider. Component-owned
* text is declared in `timelineMorfo.texts`; this file only holds the refs the
* provider needs at runtime (the list's accessible-name fallback).
*/
export const TIMELINE_LANGS = {
LABEL: '#?components.timeline.label|Timeline'
} as const;

@ -0,0 +1,179 @@
// @vitest-environment jsdom
import { tick } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { TimelineItemProvider, TimelineProvider } from './timeline-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
langs: {
ts: vi.fn(() => 'Timeline')
},
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(TimelineProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(TimelineItemProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function timelineOpts(active: number | null, live = false, root = document.createElement('ol')) {
return {
id: state('timeline-root'),
ref: state<HTMLElement | null>(root),
active: state<number | null>(active),
live: state(live),
ariaLabel: state<string | undefined>(undefined)
};
}
function itemOpts(index: number | undefined, opts?: { status?: 'done' | 'current' | 'upcoming' }) {
return {
id: state(`timeline-item-${index ?? 'auto'}`),
ref: state<HTMLElement | null>(document.createElement('li')),
index: state<number | undefined>(index),
status: state<'done' | 'current' | 'upcoming' | undefined>(opts?.status),
intent: state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss' | undefined>(undefined)
};
}
describe('TimelineProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('derives item status from the active index and projects runtime attrs', async () => {
const { dom } = installSomaHarness();
const root = document.createElement('ol');
const firstEl = document.createElement('li');
const currentEl = document.createElement('li');
const lastEl = document.createElement('li');
document.body.append(root, firstEl, currentEl, lastEl);
const { result, cleanup } = withEffectRoot(() => {
const provider = TimelineProvider.create(timelineOpts(1, false, root));
vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider);
const first = TimelineItemProvider.create({ ...itemOpts(0), ref: state<HTMLElement | null>(firstEl) });
const current = TimelineItemProvider.create({ ...itemOpts(1), ref: state<HTMLElement | null>(currentEl) });
const last = TimelineItemProvider.create({ ...itemOpts(2), ref: state<HTMLElement | null>(lastEl) });
return { provider, first, current, last };
});
expect(result.first.effectiveStatus).toBe('done');
expect(result.current.effectiveStatus).toBe('current');
expect(result.last.effectiveStatus).toBe('upcoming');
expect(result.current.isCurrent).toBe(true);
// renderProps resolves the morfo attrs into the props object the wrapper
// spreads onto the element; assert the object (no wrapper in this harness).
expect(result.provider.props).toMatchObject({
id: 'timeline-root',
'data-timeline': '',
role: 'list',
'aria-label': 'Timeline'
});
expect(result.first.props).toMatchObject({ 'data-timeline-item': '', 'data-status': 'done' });
expect(result.current.props).toMatchObject({
'data-status': 'current',
'aria-current': 'step'
});
expect(result.last.props).toMatchObject({ 'data-status': 'upcoming' });
expect(result.first.props['aria-current']).toBeUndefined();
await tick();
cleanup();
dom.dispose();
});
it('lets an explicit status override the derived value and tints with intent', async () => {
const { dom } = installSomaHarness();
const root = document.createElement('ol');
const el = document.createElement('li');
document.body.append(root, el);
const { result, cleanup } = withEffectRoot(() => {
const provider = TimelineProvider.create(timelineOpts(2, false, root));
vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider);
// index 0 would derive 'done', but the explicit status wins.
const item = TimelineItemProvider.create({
...itemOpts(0, { status: 'current' }),
ref: state<HTMLElement | null>(el),
intent: state<'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss' | undefined>('threat')
});
return { provider, item };
});
expect(result.item.effectiveStatus).toBe('current');
expect(result.item.props).toMatchObject({
'data-status': 'current',
'data-intent': 'threat',
'aria-current': 'step'
});
cleanup();
dom.dispose();
});
it('auto-indexes items by registration order when no index is passed', () => {
const { dom } = installSomaHarness();
const root = document.createElement('ol');
const { result, cleanup } = withEffectRoot(() => {
const provider = TimelineProvider.create(timelineOpts(1, false, root));
vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider);
const a = TimelineItemProvider.create(itemOpts(undefined));
const b = TimelineItemProvider.create(itemOpts(undefined));
const c = TimelineItemProvider.create(itemOpts(undefined));
return { a, b, c };
});
expect([result.a.index, result.b.index, result.c.index]).toEqual([0, 1, 2]);
expect(result.a.effectiveStatus).toBe('done');
expect(result.b.effectiveStatus).toBe('current');
expect(result.c.effectiveStatus).toBe('upcoming');
cleanup();
dom.dispose();
});
it('leaves status undefined when neither active nor explicit status is set', () => {
const { dom } = installSomaHarness();
const { result, cleanup } = withEffectRoot(() => {
const provider = TimelineProvider.create(timelineOpts(null));
vi.spyOn(TimelineProvider, 'require').mockReturnValue(provider);
const item = TimelineItemProvider.create(itemOpts(0));
return { item };
});
expect(result.item.effectiveStatus).toBeUndefined();
cleanup();
dom.dispose();
});
});

@ -0,0 +1,186 @@
import { untrack } from 'svelte';
import { context, type WithRefOpts } from '../../provider';
import { type ActiveProps } from '$libs/reactive';
import type { Intent } from '$uix/intent';
import { Soma } from '../../core/soma.svelte';
import { TIMELINE_LANGS } from './langs';
import { timelineMorfo } from '../../../morfo/components/timeline';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { TimelineStatus } from './types';
// ── Root ─────────────────────────────────────────────────────────────────────
interface TimelineOpts
extends
WithRefOpts,
ActiveProps<{
/** Active index — derives each item's status by position. `null` disables derivation. */
active: number | null;
/** Feed mode — items announce their arrival (`emerge-reveal`) as they are appended. */
live: boolean;
/** Accessible label for the list. Falls back to the translated "Timeline". */
ariaLabel: string | undefined;
}> {}
export class TimelineProvider {
readonly opts: TimelineOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<TimelineProvider>('Timeline');
static get(): TimelineProvider | undefined {
return this.ctx.getOr(undefined) as TimelineProvider | undefined;
}
static require(): TimelineProvider {
return this.ctx.get();
}
static create(opts: TimelineOpts) {
return new TimelineProvider(opts);
}
// Auto index handed to items that don't pass an explicit `index`.
private _nextIndex = 0;
// Flips true once the initial batch has mounted; later appends announce.
private _ready = false;
get ready(): boolean {
return this._ready;
}
private constructor(opts: TimelineOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(timelineMorfo, {
props: { live: () => this.opts.live.current },
translate: (key) => this.soma.langs.ts(key)
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: TimelineProvider.ctx
});
// Parent effects run after children construct, so every item in the
// initial render reads `ready === false` (no emerge); only items
// appended afterwards see `ready === true`.
$effect(() => {
this._ready = true;
});
}
get live(): boolean {
return this.opts.live.current;
}
/** Hand out the next auto index in registration order. */
registerItem(): number {
return this._nextIndex++;
}
/** Resolve an item's effective status — explicit prop wins, else derive from `active`. */
resolveStatus(index: number, explicit: TimelineStatus | undefined): TimelineStatus | undefined {
if (explicit) return explicit;
const active = this.opts.active.current;
if (active == null) return undefined;
if (index < active) return 'done';
if (index === active) return 'current';
return 'upcoming';
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
// data-timeline, role='list' and data-live are morfo-declared;
// renderProps resolves them from the registered `live` source.
...this.runtimePart.renderProps(),
// Soma override — honour an explicit `ariaLabel`, falling back to the
// morfo's translated default (same key).
'aria-label': this.opts.ariaLabel.current || this.soma.langs.ts(TIMELINE_LANGS.LABEL)
} as const)
);
}
// ── Item ─────────────────────────────────────────────────────────────────────
interface TimelineItemOpts
extends
WithRefOpts,
ActiveProps<{
/** Position in the list. Auto-assigned by registration order when omitted. */
index: number | undefined;
/** Explicit status — overrides the value derived from the root `active`. */
status: TimelineStatus | undefined;
/** Evaluative tint for the marker; also drives the `emerge-reveal` intent. */
intent: Intent | undefined;
}> {}
export class TimelineItemProvider {
readonly opts: TimelineItemOpts;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
readonly provider: TimelineProvider;
readonly index: number;
static readonly ctx = context<TimelineItemProvider>('TimelineItem');
static get(): TimelineItemProvider | undefined {
return this.ctx.getOr(undefined) as TimelineItemProvider | undefined;
}
static require(): TimelineItemProvider {
return this.ctx.get();
}
static create(opts: TimelineItemOpts) {
return new TimelineItemProvider(opts);
}
private constructor(opts: TimelineItemOpts) {
this.opts = opts;
this.provider = TimelineProvider.require();
this.index = opts.index.current ?? this.provider.registerItem();
// Freeze the emerge decision at construction: only an item mounting into
// an already-live feed (an append after the initial batch) announces.
const shouldEmerge = this.provider.live && this.provider.ready;
// Per-item runtime so the `emerge-reveal` event resolves THIS item's
// `intent` (a shared runtime can't disambiguate one item from another).
this.runtime = this.provider.soma.runtime(timelineMorfo, {
props: {
status: () => this.effectiveStatus,
intent: () => this.opts.intent.current ?? undefined
},
translate: (key) => this.provider.soma.langs.ts(key)
});
this.runtimePart = this.runtime.part('item', {
id: opts.id,
ref: opts.ref,
owner: this,
context: TimelineItemProvider.ctx
});
// Announce arrival once the element is attached (one-shot).
let fired = false;
$effect(() => {
if (fired || !shouldEmerge) return;
const el = opts.ref.current;
if (!el) return;
fired = true;
untrack(() => void this.runtime.trigger('emerge-reveal', { fallbackTarget: el }));
});
}
readonly effectiveStatus = $derived.by(() =>
this.provider.resolveStatus(this.index, this.opts.status.current)
);
readonly isCurrent = $derived.by(() => this.effectiveStatus === 'current');
readonly props = $derived.by(() =>
this.runtimePart.assert({
// data-timeline-item, data-status and data-intent are morfo-declared;
// renderProps resolves them from the registered status/intent sources.
...this.runtimePart.renderProps(),
// Soma override — aria-current reads the derived `current` status
// (the morfo condition would need a registered state source).
'aria-current': this.isCurrent ? ('step' as const) : undefined
} as const)
);
}

@ -0,0 +1,53 @@
import type { WithChild, Without } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
import type { Intent } from '$uix/intent';
/** Progress status of a single timeline item. */
export type TimelineStatus = 'done' | 'current' | 'upcoming';
export type TimelineProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/**
* Active index (0-based) — derives each item's status by position
* (`< active` → done, `=== active` → current, `> active` → upcoming).
* Per-item `status` overrides the derived value. `null` disables
* derivation (pure feed mode, where each item sets its own status).
* @default null
*/
active?: number | null;
/**
* Feed mode. Items appended after the initial render announce their
* arrival via the `emerge-reveal` event (the initial batch stays silent).
* @default false
*/
live?: boolean;
/** Accessible label for the list. Falls back to the translated "Timeline". */
ariaLabel?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
export type TimelineItemProps = WithChild<
{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/**
* Position in the list (0-based). Auto-assigned by registration order
* when omitted — pass it explicitly for dynamic / reorderable lists.
*/
index?: number;
/**
* Explicit status — overrides the value derived from the root `active`.
* Use it for history / feed timelines where each entry owns its state.
*/
status?: TimelineStatus;
/**
* Evaluative tint for the item's marker (the canonical UIX color-role
* channel). Also drives the `intent` of the `emerge-reveal` event in
* live feeds.
*/
intent?: Intent;
},
{ status: TimelineStatus | undefined }
> &
Without<PrimitiveDivAttributes, {}>;

@ -30,6 +30,7 @@
import { tagGroupSema } from '$uix/sema/components/tag-group';
import { tagsInputSema } from '$uix/sema/components/tags-input';
import { textareaSema } from '$uix/sema/components/textarea';
import { timelineSema } from '$uix/sema/components/timeline';
import { toastSema } from '$uix/sema/components/toast';
import { toolbarSema } from '$uix/sema/components/toolbar';
import { accordionSema } from '$uix/sema/components/accordion';
@ -100,6 +101,7 @@
tagGroupSema,
tagsInputSema,
textareaSema,
timelineSema,
toastSema,
toolbarSema
]
@ -235,6 +237,7 @@
{ slug: '/uix/components/tree-view', label: 'Tree view' },
{ slug: '/uix/components/tree-grid', label: 'Tree grid' },
{ slug: '/uix/components/feed', label: 'Feed' },
{ slug: '/uix/components/timeline', label: 'Timeline' },
{ slug: '/uix/components/virtual-list', label: 'Virtual list' },
{ slug: '/uix/components/virtual-grid', label: 'Virtual grid' }
]

@ -0,0 +1,607 @@
<script lang="ts">
import {
Timeline,
type TimelineAlign,
type TimelineColor,
type TimelineConnectorVariant,
type TimelineOrientation,
type TimelineSize
} from '$uix/eidos/components/timeline';
import {
CircleCheck,
CircleX,
GitCommitHorizontal,
Rocket,
RotateCcw,
Wrench,
Check
} from '$uix/eidos/components/icon';
import { compileMorfo } from '$uix/morfo';
import { timelineMorfo } from '@/uix/morfo/components/timeline';
import type { Intent } from '$uix/intent';
import type { Component } from 'svelte';
import { getActiveUix } from '$active-uix';
import { page } from '$app/state';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
const uix = getActiveUix();
const params = page.url.searchParams;
type Event = {
time: string;
iso: string;
title: string;
body: string;
icon: Component;
intent?: Intent;
};
const baseEvents: Event[] = [
{ time: '09:30', iso: '2026-06-23T09:30', title: 'Commit pushed', body: 'feat: timeline component', icon: GitCommitHorizontal },
{ time: '09:32', iso: '2026-06-23T09:32', title: 'CI passed', body: 'All checks green', icon: CircleCheck, intent: 'affirm' },
{ time: '09:40', iso: '2026-06-23T09:40', title: 'Deploy to staging', body: 'v2.4.1-rc', icon: Rocket },
{ time: '09:55', iso: '2026-06-23T09:55', title: 'Smoke test failed', body: 'timeout on /api', icon: CircleX, intent: 'threat' },
{ time: '10:10', iso: '2026-06-23T10:10', title: 'Rolled back', body: 'restored v2.4.0', icon: RotateCcw, intent: 'loss' }
];
const extraEvents: Event[] = [
{ time: '10:20', iso: '2026-06-23T10:20', title: 'Hotfix merged', body: 'patch /api timeout', icon: Wrench, intent: 'affirm' },
{ time: '10:34', iso: '2026-06-23T10:34', title: 'Re-deployed', body: 'v2.4.2 live', icon: Check, intent: 'fulfill' }
];
let tab = $state<Tab>('live');
let events = $state<Event[]>([...baseEvents]);
let orientation = $state<TimelineOrientation>(
params.get('perm.orientation') === 'horizontal' ? 'horizontal' : 'vertical'
);
let align = $state<TimelineAlign>('start');
let active = $state(4);
let live = $state(false);
let size = $state<TimelineSize>('md');
let markerSize = $state<TimelineSize>('md');
let color = $state<TimelineColor>('primary');
let connectorVariant = $state<TimelineConnectorVariant>('solid');
let appendCount = $state(0);
function appendEvent() {
const next = extraEvents[appendCount % extraEvents.length];
appendCount += 1;
const newIndex = events.length;
events = [...events, { ...next, time: next.time }];
// The freshly-arrived entry becomes the current highlight.
active = newIndex;
}
function resetEvents() {
events = [...baseEvents];
appendCount = 0;
active = 4;
}
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '-',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
const compiled = compileMorfo(timelineMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const eventsDecl = $derived([...compiled.actions.byName.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as Timeline from '$soma/components/timeline';",
'',
' let active = $state(4);',
' const events = [/* { time, title, body, intent? } */];',
'</' + 'script>',
'',
`<Timeline.Provider {active}${live ? ' live' : ''}>`,
' {#each events as ev, i}',
' <Timeline.Item index={i} status={ev.status} intent={ev.intent}>',
' {#snippet children({ status })}',
' <!-- marker · connector · time · content -->',
' {/snippet}',
' </Timeline.Item>',
' {/each}',
'</Timeline.Provider>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Timeline } from '$uix/eidos/components/timeline';",
" import { Rocket } from '$uix/eidos/components/icon';",
'',
' let active = $state(4);',
' const events = [/* { time, title, body, icon, intent? } */];',
'</' + 'script>',
'',
'<Timeline',
orientation !== 'vertical' && ` orientation="${orientation}"`,
align !== 'start' && ` align="${align}"`,
` {active}`,
live && ' live',
size !== 'md' && ` size="${size}"`,
markerSize !== 'md' && ` markerSize="${markerSize}"`,
color !== 'primary' && ` color="${color}"`,
'>',
' {#each events as ev, i}',
' {@const Ico = ev.icon}',
' <Timeline.Item index={i} intent={ev.intent}>',
' {#snippet children({ status })}',
' <Timeline.Marker><Ico /></Timeline.Marker>',
' {#if i < events.length - 1}',
connectorVariant !== 'solid'
? ` <Timeline.Connector variant="${connectorVariant}" />`
: ' <Timeline.Connector />',
' {/if}',
' <Timeline.Time datetime={ev.iso}>{ev.time}</Timeline.Time>',
' <Timeline.Content>',
' <Timeline.Title>{ev.title}</Timeline.Title>',
' <Timeline.Description>{ev.body}</Timeline.Description>',
' </Timeline.Content>',
' {/snippet}',
' </Timeline.Item>',
' {/each}',
'</Timeline>'
]
.filter(Boolean)
.join('\n')
);
</script>
<svelte:head>
<meta name="perm-axes" content="orientation" />
</svelte:head>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Data · Timeline</div>
<h1 data-uix-page-title>Timeline</h1>
<p data-uix-page-lede>
A sequence of events that unifies history/feed (per-item status + evaluative intent) with
progress (a root <code>active</code> index, stepper-style). Soma owns the status model + the
live-feed emission; Eidos owns the rail, markers, connectors, orientation and alternating
layout.
</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>{eventsDecl.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>entries</span>{events.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>active</span>{active}</span>
</div>
</header>
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
style="min-inline-size: min(100%, 44rem); padding-block: 0.5rem;"
>
<Timeline {orientation} {align} {active} {live} {size} {markerSize} {color}>
{#each events as ev, i (ev.iso + i)}
{@const Ico = ev.icon}
<Timeline.Item index={i} intent={ev.intent}>
{#snippet children({ status })}
<Timeline.Marker><Ico /></Timeline.Marker>
{#if i < events.length - 1}
<Timeline.Connector variant={connectorVariant} />
{/if}
<Timeline.Time datetime={ev.iso}>{ev.time}</Timeline.Time>
<Timeline.Content>
<Timeline.Title>{ev.title}</Timeline.Title>
<Timeline.Description>{ev.body}</Timeline.Description>
</Timeline.Content>
{/snippet}
</Timeline.Item>
{/each}
</Timeline>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>turn on <code>live</code> and append an event to fire emerge-reveal</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span>
<span data-uix-stage-trace-event>{entry.event}</span>
· {entry.family}{entry.intent ? ' · ' + entry.intent : ''}
</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>live</span>
{live ? '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 === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>8</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {eventsDecl.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{eventsDecl.length}</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>
<span data-uix-layer-badge="soma">soma</span> owns the status model + live feed.
<span data-uix-layer-badge="eidos">eidos</span> owns the rail, markers and layout. Items 2/4/5
carry an <code>intent</code> so you can see the semantic axis next to the progress accent.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · status + feed
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>active <span data-uix-control-hint>{active}</span></span>
<input type="range" bind:value={active} min="0" max={events.length} />
</label>
<label data-uix-control>
<span data-uix-control-label>live</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={live} />
<span data-uix-switch-label>{live ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>feed</span>
<span data-uix-chips role="group">
<button data-uix-chip onclick={appendEvent}>+ append event</button>
<button data-uix-chip onclick={resetEvents}>reset</button>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · layout + visual
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each ['vertical', 'horizontal'] as o}
<button
data-uix-chip
data-active={orientation === o}
onclick={() => (orientation = o as TimelineOrientation)}
>
{o}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>align</span>
<span data-uix-chips role="radiogroup">
{#each ['start', 'end', 'alternate'] as a}
<button data-uix-chip data-active={align === a} onclick={() => (align = a as TimelineAlign)}>
{a}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size (type)</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as TimelineSize)}>
{s}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>marker size (circle)</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as s}
<button
data-uix-chip
data-active={markerSize === s}
onclick={() => (markerSize = s as TimelineSize)}
>
{s}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color (accent)</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill'] as c}
<button
data-uix-chip
data-active={color === c}
onclick={() => (color = c as TimelineColor)}
>
{c}
</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>connector</span>
<span data-uix-chips role="radiogroup">
{#each ['solid', 'dashed', 'dotted'] as v}
<button
data-uix-chip
data-active={connectorVariant === v}
onclick={() => (connectorVariant = v as TimelineConnectorVariant)}
>
{v}
</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · status model + feed</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · timeline recipe</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API + reference comparison</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Feature</th><th>MUI</th><th>AntD</th><th>Mantine</th><th>Chakra v3</th><th>UIX</th><th>Decision</th></tr>
</thead>
<tbody>
<tr><td class="name">Compositional parts</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>Marker · Connector · Time · Content.</td></tr>
<tr><td class="name">Vertical + horizontal</td><td>V</td><td>V</td><td>V</td><td>V</td><td>✅</td><td>Both, logical-RTL.</td></tr>
<tr><td class="name">Alternating sides</td><td>✅</td><td>✅</td><td>❌</td><td>❌</td><td>✅</td><td><code>align="alternate"</code>.</td></tr>
<tr><td class="name">Progress (status + active)</td><td>❌</td><td>❌</td><td>✅</td><td>❌</td><td>✅</td><td>Unified with Stepper.</td></tr>
<tr><td class="name">Marker by intent (canon)</td><td>tint</td><td>tint</td><td>tint</td><td>tint</td><td>✅</td><td>Canonical intent palette + sema.</td></tr>
<tr><td class="name">Connector dashed/dotted</td><td>❌</td><td>❌</td><td>✅</td><td>✅</td><td>✅</td><td>Per-connector <code>variant</code>.</td></tr>
<tr><td class="name">Live feed (motion + sema)</td><td>❌</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td><td>Reveal + <code>emerge-reveal</code>.</td></tr>
<tr><td class="name">Pending / loading item</td><td>❌</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>Deferred to v2 (sustain status).</td></tr>
</tbody>
</table>
</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">active</td><td>soma</td><td class="default">null</td><td>Derives item status by position.</td></tr>
<tr><td class="name">live</td><td>soma</td><td class="default">false</td><td>Appended items fire emerge-reveal.</td></tr>
<tr><td class="name">status (item)</td><td>soma</td><td class="default">derived</td><td>done | current | upcoming; overrides active.</td></tr>
<tr><td class="name">intent (item)</td><td>soma</td><td class="default">—</td><td>Marker tint + emerge intent.</td></tr>
<tr><td class="name">orientation</td><td>eidos</td><td class="default">vertical</td><td>vertical | horizontal.</td></tr>
<tr><td class="name">align</td><td>eidos</td><td class="default">start</td><td>start | end | alternate.</td></tr>
<tr><td class="name">size</td><td>eidos</td><td class="default">md</td><td>Type + spacing density (sm | md | lg).</td></tr>
<tr><td class="name">markerSize</td><td>eidos</td><td class="default">md</td><td>Circle diameter, independent of size.</td></tr>
<tr><td class="name">color</td><td>eidos</td><td class="default">primary</td><td>Status accent (progressive roles).</td></tr>
<tr><td class="name">variant (connector)</td><td>eidos</td><td class="default">solid</td><td>solid | dashed | dotted.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<p data-uix-section-desc>
The morfo declares only the behavioral / semantic surface (status, intent, aria-current,
live). The visual axes are eidos-only attrs reached on the children via descendant selectors.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>States</th></tr></thead>
<tbody>
{#each partsList as part}
<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.role ?? '-'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#each timelineMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{ attr: string; value: { kind: string }; severity?: string }>;
}}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{#if dataAttrs.length || ariaAttrs.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Attribute</th><th>Values</th><th>Source</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '-'}</td>
<td class="default">
{'value' in attr && attr.value
? (attr as { value: { kind: string } }).value.kind
: '-'}
</td>
</tr>
{/each}
{#each ariaAttrs as a}
<tr><td class="name">{a.attr}</td><td class="type">-</td><td>{a.value.kind}</td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/each}
<div data-uix-subsection-head>Events</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Target</th></tr></thead>
<tbody>
{#each eventsDecl as action}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '-'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td>{typeof action.semantic.intent === 'string' ? action.semantic.intent : 'fromProp'}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events + perceptual signature
</h2>
<p data-uix-section-desc>
Timeline emits <code>emerge-reveal</code> when an item appends into a live feed. Its intent
comes from the item (affirm = a good event landed, threat = a failure surfaced). Play it on
the last entry's marker.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Intent</th><th>Target</th><th>Play</th></tr></thead>
<tbody>
{#each eventsDecl as action}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '-'}</td>
<td>{typeof action.semantic.intent === 'string' ? action.semantic.intent : 'fromProp'}</td>
<td>{action.target}</td>
<td>
<button
data-uix-play
onclick={() => {
const items = stageRef?.querySelectorAll('[data-timeline-item]');
const target = (items?.[items.length - 1] ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
intent: 'affirm',
target
});
}}
>
▶ play
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors live in <code>src/uix/eidos/components/timeline/timeline.css</code>. Status drives
the marker treatment; intent overrides the hue; the connector renders as a logical border.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr><td class="name">[data-timeline-item][data-status]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-timeline-item][data-intent]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-timeline][data-live]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-timeline][data-orientation]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-timeline][data-align]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-timeline][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-timeline][data-color]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-timeline-connector][data-variant]</td><td><span data-uix-tag data-kind="eidos">eidos</span></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">Structure</td><td>An ordered <code>&lt;ol&gt;</code> with <code>role="list"</code>; each entry is an <code>&lt;li&gt;</code>.</td></tr>
<tr><td class="name">Active item</td><td>The current item exposes <code>aria-current="step"</code>.</td></tr>
<tr><td class="name">Marker / connector</td><td>Decorative — <code>aria-hidden="true"</code>. The list text carries meaning.</td></tr>
<tr><td class="name">Label</td><td>The list has a translated <code>aria-label</code> (override via <code>ariaLabel</code>).</td></tr>
<tr><td class="name">Reduced motion</td><td>The current-marker pulse and the live-feed reveal are disabled under <code>prefers-reduced-motion</code>.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.