` | eidos | Entry body (Title + Description). |
+| `Title` | `
` | eidos | Heading (`as` overrides the level). |
+| `Description` | `
` | eidos | Secondary prose. |
+
+## Visual props (``)
+
+| 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. |
+
+`` 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).
diff --git a/src/uix/eidos/components/timeline/index.ts b/src/uix/eidos/components/timeline/index.ts
new file mode 100644
index 000000000..b11454914
--- /dev/null
+++ b/src/uix/eidos/components/timeline/index.ts
@@ -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';
diff --git a/src/uix/eidos/components/timeline/timeline-connector.svelte b/src/uix/eidos/components/timeline/timeline-connector.svelte
new file mode 100644
index 000000000..b89a688f1
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline-connector.svelte
@@ -0,0 +1,11 @@
+
+
+
diff --git a/src/uix/eidos/components/timeline/timeline-content.svelte b/src/uix/eidos/components/timeline/timeline-content.svelte
new file mode 100644
index 000000000..0c55e47d9
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline-content.svelte
@@ -0,0 +1,10 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/uix/eidos/components/timeline/timeline-description.svelte b/src/uix/eidos/components/timeline/timeline-description.svelte
new file mode 100644
index 000000000..ea48d57f4
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline-description.svelte
@@ -0,0 +1,10 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/uix/eidos/components/timeline/timeline-item.svelte b/src/uix/eidos/components/timeline/timeline-item.svelte
new file mode 100644
index 000000000..a152a513e
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline-item.svelte
@@ -0,0 +1,12 @@
+
+
+
+ {#snippet children(args: { status: TimelineStatus | undefined })}
+ {@render bodyContent?.(args)}
+ {/snippet}
+
diff --git a/src/uix/eidos/components/timeline/timeline-marker.svelte b/src/uix/eidos/components/timeline/timeline-marker.svelte
new file mode 100644
index 000000000..76485cb99
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline-marker.svelte
@@ -0,0 +1,10 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/uix/eidos/components/timeline/timeline-time.svelte b/src/uix/eidos/components/timeline/timeline-time.svelte
new file mode 100644
index 000000000..94deb1b65
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline-time.svelte
@@ -0,0 +1,10 @@
+
+
+
diff --git a/src/uix/eidos/components/timeline/timeline-title.svelte b/src/uix/eidos/components/timeline/timeline-title.svelte
new file mode 100644
index 000000000..6fe208ad3
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline-title.svelte
@@ -0,0 +1,10 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/uix/eidos/components/timeline/timeline.css b/src/uix/eidos/components/timeline/timeline.css
new file mode 100644
index 000000000..e6f1e6456
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline.css
@@ -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 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;
+ }
+}
diff --git a/src/uix/eidos/components/timeline/timeline.svelte b/src/uix/eidos/components/timeline/timeline.svelte
new file mode 100644
index 000000000..9eb7bc18d
--- /dev/null
+++ b/src/uix/eidos/components/timeline/timeline.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {@render bodyContent?.()}
+
diff --git a/src/uix/eidos/components/timeline/types.ts b/src/uix/eidos/components/timeline/types.ts
new file mode 100644
index 000000000..1ffb5254e
--- /dev/null
+++ b/src/uix/eidos/components/timeline/types.ts
@@ -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;
+/** 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;
+ /** Circle (marker) diameter. Independent of the type size. @default 'md' */
+ markerSize?: ResponsiveProp;
+ /** 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, 'children'> & {
+ children?: Snippet;
+};
+
+export type TimelineConnectorProps = Omit, 'children'> & {
+ /** Line style. @default 'solid' */
+ variant?: TimelineConnectorVariant;
+};
+
+export type TimelineTimeProps = Omit & {
+ children?: Snippet;
+};
+
+export type TimelineContentProps = Omit, 'children'> & {
+ children?: Snippet;
+};
+
+export type TimelineTitleProps = Omit, 'children'> & {
+ /** Heading level override. @default 'h3' */
+ as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+ children?: Snippet;
+};
+
+export type TimelineDescriptionProps = Omit, 'children'> & {
+ children?: Snippet;
+};
+
+export type { TimelineStatus };
diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css
index 57f54108c..1641ec9bd 100644
--- a/src/uix/eidos/generated/base.css
+++ b/src/uix/eidos/generated/base.css
@@ -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;
diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts
index 863830254..d6929517e 100644
--- a/src/uix/eidos/lib/recipes/base.ts
+++ b/src/uix/eidos/lib/recipes/base.ts
@@ -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',
diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts
index d180f1b5e..4e76178f5 100644
--- a/src/uix/langs/components/index.ts
+++ b/src/uix/langs/components/index.ts
@@ -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,
diff --git a/src/uix/langs/components/timeline.ts b/src/uix/langs/components/timeline.ts
new file mode 100644
index 000000000..fce4d375e
--- /dev/null
+++ b/src/uix/langs/components/timeline.ts
@@ -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;
diff --git a/src/uix/morfo/components/timeline.ts b/src/uix/morfo/components/timeline.ts
new file mode 100644
index 000000000..6d7e9baac
--- /dev/null
+++ b/src/uix/morfo/components/timeline.ts
@@ -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 `` / `- ` 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 `
- `, 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 `
` 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;
diff --git a/src/uix/sema/components/index.ts b/src/uix/sema/components/index.ts
index 2df98f36b..642448322 100644
--- a/src/uix/sema/components/index.ts
+++ b/src/uix/sema/components/index.ts
@@ -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';
diff --git a/src/uix/sema/components/timeline.ts b/src/uix/sema/components/timeline.ts
new file mode 100644
index 000000000..371c684cd
--- /dev/null
+++ b/src/uix/sema/components/timeline.ts
@@ -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>[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' }
+ }
+ ]
+};
diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts
index 8fc7aa707..1e45ba508 100644
--- a/src/uix/soma/components/index.ts
+++ b/src/uix/soma/components/index.ts
@@ -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';
diff --git a/src/uix/soma/components/timeline/README.md b/src/uix/soma/components/timeline/README.md
new file mode 100644
index 000000000..149392d6f
--- /dev/null
+++ b/src/uix/soma/components/timeline/README.md
@@ -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:
+`` / `- ` semantics, `aria-current="step"` on the active item, decorative
+markers and connectors. Interactive step-jumping stays with `Stepper`.
+
+## Anatomy
+
+```svelte
+
+
+
+
+ 09:30
+
+ Deploy ok
+ v2.4.1 to production
+
+
+
+
+ 2
+
+ Running migrations
+
+
+
+```
+
+> `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` | `
` | Root list. Tracks `active`, auto-indexes items, gates the live feed. |
+| `Item` | `- ` | 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.
diff --git a/src/uix/soma/components/timeline/components/timeline-item.svelte b/src/uix/soma/components/timeline/components/timeline-item.svelte
new file mode 100644
index 000000000..080b597d5
--- /dev/null
+++ b/src/uix/soma/components/timeline/components/timeline-item.svelte
@@ -0,0 +1,41 @@
+
+
+{#if child}
+ {@render child({ status: state.effectiveStatus, props: mergedProps })}
+{:else}
+
-
+ {@render children?.({ status: state.effectiveStatus })}
+
+{/if}
diff --git a/src/uix/soma/components/timeline/components/timeline.svelte b/src/uix/soma/components/timeline/components/timeline.svelte
new file mode 100644
index 000000000..654933ba5
--- /dev/null
+++ b/src/uix/soma/components/timeline/components/timeline.svelte
@@ -0,0 +1,41 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/timeline/exports.ts b/src/uix/soma/components/timeline/exports.ts
new file mode 100644
index 000000000..e88f87f7f
--- /dev/null
+++ b/src/uix/soma/components/timeline/exports.ts
@@ -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';
diff --git a/src/uix/soma/components/timeline/index.ts b/src/uix/soma/components/timeline/index.ts
new file mode 100644
index 000000000..8570a426a
--- /dev/null
+++ b/src/uix/soma/components/timeline/index.ts
@@ -0,0 +1 @@
+export * from './exports';
diff --git a/src/uix/soma/components/timeline/langs.ts b/src/uix/soma/components/timeline/langs.ts
new file mode 100644
index 000000000..a9c427b86
--- /dev/null
+++ b/src/uix/soma/components/timeline/langs.ts
@@ -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;
diff --git a/src/uix/soma/components/timeline/timeline-provider.svelte.test.ts b/src/uix/soma/components/timeline/timeline-provider.svelte.test.ts
new file mode 100644
index 000000000..b6129f16e
--- /dev/null
+++ b/src/uix/soma/components/timeline/timeline-provider.svelte.test.ts
@@ -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(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) =>
+ 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(root),
+ active: state(active),
+ live: state(live),
+ ariaLabel: state(undefined)
+ };
+}
+
+function itemOpts(index: number | undefined, opts?: { status?: 'done' | 'current' | 'upcoming' }) {
+ return {
+ id: state(`timeline-item-${index ?? 'auto'}`),
+ ref: state(document.createElement('li')),
+ index: state(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(firstEl) });
+ const current = TimelineItemProvider.create({ ...itemOpts(1), ref: state(currentEl) });
+ const last = TimelineItemProvider.create({ ...itemOpts(2), ref: state(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(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();
+ });
+});
diff --git a/src/uix/soma/components/timeline/timeline-provider.svelte.ts b/src/uix/soma/components/timeline/timeline-provider.svelte.ts
new file mode 100644
index 000000000..2229d7ab3
--- /dev/null
+++ b/src/uix/soma/components/timeline/timeline-provider.svelte.ts
@@ -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('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('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)
+ );
+}
diff --git a/src/uix/soma/components/timeline/types.ts b/src/uix/soma/components/timeline/types.ts
new file mode 100644
index 000000000..1a10ce8cc
--- /dev/null
+++ b/src/uix/soma/components/timeline/types.ts
@@ -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;
+
+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;
diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte
index d9bc24af0..5460ae163 100644
--- a/web/routes/uix/+layout@.svelte
+++ b/web/routes/uix/+layout@.svelte
@@ -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' }
]
diff --git a/web/routes/uix/components/timeline/+page.svelte b/web/routes/uix/components/timeline/+page.svelte
new file mode 100644
index 000000000..e4b1e64d1
--- /dev/null
+++ b/web/routes/uix/components/timeline/+page.svelte
@@ -0,0 +1,607 @@
+
+
+
+
+
+
+
+
+ Data · Timeline
+ Timeline
+
+ A sequence of events that unifies history/feed (per-item status + evaluative intent) with
+ progress (a root active index, stepper-style). Soma owns the status model + the
+ live-feed emission; Eidos owns the rail, markers, connectors, orientation and alternating
+ layout.
+
+
+ parts{compiled.parts.order.length}
+ events{eventsDecl.length}
+ entries{events.length}
+ active{active}
+
+
+
+
+
+
+ {#each events as ev, i (ev.iso + i)}
+ {@const Ico = ev.icon}
+
+ {#snippet children({ status })}
+
+ {#if i < events.length - 1}
+
+ {/if}
+ {ev.time}
+
+ {ev.title}
+ {ev.body}
+
+ {/snippet}
+
+ {/each}
+
+
+
+ trace
+ {#if trace.length === 0}
+ turn on live and append an event to fire emerge-reveal
+ {:else}
+ {#each trace.slice(0, 3) as entry}
+
+ {entry.event}
+ · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}
+
+ {fmtTime(entry.at)}
+ {/each}
+ {/if}
+
+ live
+ {live ? 'on' : 'off'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if tab === 'live'}
+
+ Controls
+
+ soma owns the status model + live feed.
+ eidos owns the rail, markers and layout. Items 2/4/5
+ carry an intent so you can see the semantic axis next to the progress accent.
+
+
+
+ soma props · status + feed
+
+
+
+
+
+
+
+
+ eidos props · layout + visual
+
+
+
+
+
+
+
+
+
+
+
+
+ soma
+ headless · status model + feed
+ svelte
+
+
{somaSnippet}
+
+
+
+ eidos
+ visual · timeline recipe
+ svelte
+
+
{eidosSnippet}
+
+
+ {/if}
+
+ {#if tab === 'api'}
+
+ API + reference comparison
+
+
+
+ | Feature | MUI | AntD | Mantine | Chakra v3 | UIX | Decision |
+
+
+ | Compositional parts | ✅ | ✅ | ✅ | ✅ | ✅ | Marker · Connector · Time · Content. |
+ | Vertical + horizontal | V | V | V | V | ✅ | Both, logical-RTL. |
+ | Alternating sides | ✅ | ✅ | ❌ | ❌ | ✅ | align="alternate". |
+ | Progress (status + active) | ❌ | ❌ | ✅ | ❌ | ✅ | Unified with Stepper. |
+ | Marker by intent (canon) | tint | tint | tint | tint | ✅ | Canonical intent palette + sema. |
+ | Connector dashed/dotted | ❌ | ❌ | ✅ | ✅ | ✅ | Per-connector variant. |
+ | Live feed (motion + sema) | ❌ | ❌ | ❌ | ❌ | ✅ | Reveal + emerge-reveal. |
+ | Pending / loading item | ❌ | ✅ | ❌ | ❌ | ❌ | Deferred to v2 (sustain status). |
+
+
+
+
+
+ | Prop | Owner | Default | Notes |
+
+ | active | soma | null | Derives item status by position. |
+ | live | soma | false | Appended items fire emerge-reveal. |
+ | status (item) | soma | derived | done | current | upcoming; overrides active. |
+ | intent (item) | soma | — | Marker tint + emerge intent. |
+ | orientation | eidos | vertical | vertical | horizontal. |
+ | align | eidos | start | start | end | alternate. |
+ | size | eidos | md | Type + spacing density (sm | md | lg). |
+ | markerSize | eidos | md | Circle diameter, independent of size. |
+ | color | eidos | primary | Status accent (progressive roles). |
+ | variant (connector) | eidos | solid | solid | dashed | dotted. |
+
+
+
+
+ {/if}
+
+ {#if tab === 'morfo'}
+
+ Morfo contract
+
+ 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.
+
+
+
+ | Part | Marker | Element | Role | States |
+
+ {#each partsList as part}
+
+ | {part.kebab} |
+ [{part.marker}] |
+ <{part.defaultElement}> |
+ {part.role ?? '-'} |
+ {part.states.length ? part.states.join(' | ') : '-'} |
+
+ {/each}
+
+
+
+
+ {#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}
+ {partAny.kebab}
+
+
+ | Attribute | Values | Source |
+
+ {#each dataAttrs as attr}
+
+ | {attr.attr} |
+ {attr.values ? attr.values.join(' | ') : '-'} |
+
+ {'value' in attr && attr.value
+ ? (attr as { value: { kind: string } }).value.kind
+ : '-'}
+ |
+
+ {/each}
+ {#each ariaAttrs as a}
+ | {a.attr} | - | {a.value.kind} |
+ {/each}
+
+
+
+ {/if}
+ {/each}
+
+ Events
+
+
+ | Name | Family | Verb | Sequence | Intent | Target |
+
+ {#each eventsDecl as action}
+
+ | {action.name} |
+ {action.semantic.family} |
+ {action.semantic.verb ?? '-'} |
+ {action.semantic.sequence ?? 'pre'} |
+ {typeof action.semantic.intent === 'string' ? action.semantic.intent : 'fromProp'} |
+ {action.target} |
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if tab === 'sema'}
+
+
+ sema · events + perceptual signature
+
+
+ Timeline emits emerge-reveal 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.
+
+
+
+ | Name | Family | Verb | Intent | Target | Play |
+
+ {#each eventsDecl as action}
+
+ | {action.name} |
+ {action.semantic.family} |
+ {action.semantic.verb ?? '-'} |
+ {typeof action.semantic.intent === 'string' ? action.semantic.intent : 'fromProp'} |
+ {action.target} |
+
+
+ |
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if tab === 'recipe'}
+
+ Eidos recipe
+
+ Selectors live in src/uix/eidos/components/timeline/timeline.css. Status drives
+ the marker treatment; intent overrides the hue; the connector renders as a logical border.
+
+
+
+ | Selector | Source |
+
+ | [data-timeline-item][data-status] | morfo |
+ | [data-timeline-item][data-intent] | morfo |
+ | [data-timeline][data-live] | morfo |
+ | [data-timeline][data-orientation] | eidos |
+ | [data-timeline][data-align] | eidos |
+ | [data-timeline][data-size] | eidos |
+ | [data-timeline][data-color] | eidos |
+ | [data-timeline-connector][data-variant] | eidos |
+
+
+
+
+ {/if}
+
+ {#if tab === 'a11y'}
+
+ Accessibility
+
+
+ | Concern | Contract |
+
+ | Structure | An ordered <ol> with role="list"; each entry is an <li>. |
+ | Active item | The current item exposes aria-current="step". |
+ | Marker / connector | Decorative — aria-hidden="true". The list text carries meaning. |
+ | Label | The list has a translated aria-label (override via ariaLabel). |
+ | Reduced motion | The current-marker pulse and the live-feed reveal are disabled under prefers-reduced-motion. |
+
+
+
+
+ {/if}
+