feat(eidos): Metrics — KPI/stat component (Untitled/shadcn-grade)

New eidos-native Metrics component (morfo scope sema+eidos, soma runtime
for the live signal). Parts: featured Icon, Label, Value, Delta (direction
⟂ valence via goodTrend), Trend row (delta + caption inline), Chart
(composes Sparkline), Progress + Gauge (compose Meter), Actions, Description.
Opt-in `live` turns a value change into `signal-notify-update` — flashes the
value, pings sema, announces the new value.

Reference-grade hierarchy: the value is rebased ~2 steps UP the canonical
type scale (md→font-xl, lg→font-xxl) so the small-label → big-number jump
reads as a KPI (the 1:1 mapping collapsed it). Tight rhythm (label→value 8px,
value→trend 14px); featured icon = fixed 48px soft rounded-square.

Demo at /uix/components/metrics: Live (interactive) + Gallery (14 real
compositions, outline cards).

Also: fix Skeleton light-mode visibility (neutral-track is ~white in light,
so the placeholder vanished on a white surface). Defer exposing the full
31-scale palette in component `color` + demos to pendiente_color_demos.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 3 months ago
parent c945ac00d5
commit 31671bb323

@ -0,0 +1,205 @@
# Metrics
A metric / **KPI** readout — label, headline value and a change delta, with an
optional **featured icon**, **trend sparkline** and **actions** row. Eidos-native
primitive (`scope: ['sema', 'eidos']`, no soma provider), modeled on
[`card`](../card/) and [`badge`](../badge/).
It covers the three Untitled-UI metric families — **Simple**, **Icon**, **Chart**
— each optionally with an **Actions** row, by composing existing framework
primitives.
> **Surfaceless by design.** Metrics is a content block, not a tile. For the "KPI
> card" look, compose it inside [`<Card>`](../card/). For a clickable drill-down,
> wrap it in `<Card interactive onSelect={…}>` — Card owns the focus + keyboard +
> `commit-select`, so Metrics does not duplicate it.
```svelte
<Card variant="outline">
<Metrics live>
<Metrics.Icon variant="soft" color="primary"><DollarSign /></Metrics.Icon>
<Metrics.Label>Total revenue</Metrics.Label>
<Metrics.Value value={72540}>${72540}</Metrics.Value>
<Metrics.Trend>
<Metrics.Delta trend="up" value="12%" />
<Metrics.Description>vs. last month</Metrics.Description>
</Metrics.Trend>
<Metrics.Chart data={series} x="day" y="amount" />
<Metrics.Actions><Link href="/report">View report</Link></Metrics.Actions>
</Metrics>
</Card>
```
The hierarchy IS the look: a small muted **label** sits above a large bold
**value** (the value is rebased ~2 steps up the canonical type scale, so the
14 → ~28px jump reads as a KPI), then a compact **trend row** — the `Delta` pill
and the comparison caption on one baseline (`↑12% vs last month`). Wrap them in
`<Metrics.Trend>` for that row; left them as bare siblings and they stack.
## Why it's different
Most systems don't ship a first-class metric at all (Radix, Ark, MUI, shadcn —
you compose by hand). The ones that do (Chakra `Stat`, Tremor `Metric` +
`BadgeDelta`, Untitled UI metrics) **hardcode green-up / red-down**. Metrics
improves on all of them through the framework's own canon:
1. **Direction ⟂ valence.** `data-trend` (the arrow: up / down / flat) is
decoupled from `intent` (the colour). `goodTrend="down"` makes a metric where
_falling is good_ (churn, cost, latency, bounce) paint a **down arrow in an
affirmative tone** — the case every other library gets wrong.
2. **Theme- and a11y-grade for free.** The delta + featured-icon + sparkline
colours come from the canonical `intent → colour` tokens, so they inherit
light/dark, forced-colors (HCM), `prefers-contrast` and wide-gamut P3 that the
colour engine already solves. No hardcoded hex.
3. **Composition, not reinvention.** The Delta composes [`<Badge>`](../badge/) +
arrow [`<Icon>`](../icon/); the Chart composes [`<Sparkline>`](../chart/); the
value can be formatted with [`<FormatNumber>`](../../../../web/routes/uix/components/format-number/);
the surface is [`<Card>`](../card/); the Actions row composes [`<Link>`](../link/)
/ [`<Button>`](../button/) / [`<DropdownMenu>`](../dropdown-menu/).
4. **Perceptually live (opt-in).** `live` turns a value change into
`signal-notify-update` (book family `signal`): the value flashes, sema pings
sound/haptic, and the new value is announced to the live region — a
real-time-dashboard affordance no other metric component has.
## Anatomy
```
Metrics surfaceless flex column (data-metrics · data-size · data-align · data-live)
├─ Metrics.Icon featured icon (plain/soft/solid/outline × colour) [data-metrics-icon]
├─ Metrics.Label metric name [data-metrics-label]
├─ Metrics.Value headline figure (tabular numerals) [data-metrics-value] ← flash target
├─ Metrics.Delta change chip = <Badge> + arrow <Icon> [data-metrics-delta][data-trend]
├─ Metrics.Trend delta + caption on ONE baseline row [data-metrics-trend]
├─ Metrics.Chart trend sparkline = composed <Sparkline> [data-metrics-chart]
├─ Metrics.Progress linear goal bar = composed <Meter> [data-metrics-progress]
├─ Metrics.Gauge radial goal gauge = composed <Meter shape=circular> [data-metrics-gauge]
├─ Metrics.Actions "View report" link / dropdown row [data-metrics-actions]
└─ Metrics.Description period / context [data-metrics-description]
```
`Metrics.Progress` / `Metrics.Gauge` take `value` + `max` (the goal) + optional
`min` / `low` / `high` / `optimum` (zone thresholds, forwarded to `<Meter>`) +
`ariaLabel`. Progress' `children` render as a caption under the bar; the Gauge's
override the centred percent. The composed `<Meter>` owns the `role="meter"`
ARIA value/range.
All parts are optional except the root. They stack vertically; for a
value-beside-delta or icon-beside-content layout, compose with the framework
layout primitives ([`<Group>`](../group/) / [`<Flex>`](../flex/)).
## Props
### `<Metrics>` (root)
| Prop | Type | Default | Description |
| -------- | -------------------------------------- | ------------- | ---------------------------------------- |
| `size` | `ResponsiveProp<'xs'…'xxl'>` | `'md'` | Whole-block scale (6 steps). **Every sub-part scales with it** — value 1:1 with the canonical type scale (`--font-size-{size}`), label + description one step below, plus the delta badge and the featured icon (padding + glyph). Theme-overridable per `--metrics-value-font-size-{size}` / `--metrics-label-font-size-{size}`. Use a large step (`xl`/`xxl`) for a hero dashboard KPI; `md` is a compact metric. |
| `align` | `'start' \| 'center'` | `'start'` | Cross-axis alignment. |
| `layout` | `'icon-start' \| 'stack'` | `'icon-start'` | Featured-icon position: `icon-start` = left column + content stacked right (Untitled "Icon" family); `stack` = single column (icon on top). Only affects arrangement when an icon is present. |
| `live` | `boolean` | `false` | Fire `signal-notify-update` on a Value change. |
### `<Metrics.Delta>`
| Prop | Type | Default | Description |
| ----------- | ---------------------------------------- | ------- | --------------------------------------------------- |
| `trend` | `'up' \| 'down' \| 'flat'` | — | Direction — drives the arrow glyph. |
| `value` | `string \| number` | — | Change magnitude text; used for the a11y label. |
| `goodTrend` | `'up' \| 'down'` | `'up'` | Which direction reads as affirmative. |
| `intent` | `MetricsIntent` | derived | Override the `trend`/`goodTrend` colour derivation. |
| `variant` | `'soft' \| 'solid' \| 'outline' \| 'ghost'` | `'soft'` | Chip treatment (composed Badge). |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'sm'` | Chip size (composed Badge). |
Intent derivation when `intent` is omitted: `flat → neutral`;
`trend === goodTrend → affirm`; otherwise `→ risk`.
### `<Metrics.Icon>` (featured icon)
| Prop | Type | Default | Description |
| --------- | ------------------------------------------ | ----------- | --------------------------------- |
| `variant` | `'plain' \| 'soft' \| 'solid' \| 'outline'`| `'soft'` | Container treatment (`plain` = bare glyph). |
| `shape` | `'circle' \| 'rounded' \| 'square'` | `'rounded'` | Container silhouette (rounded-square by default; the Untitled "featured icon" look). |
| `color` | `ColorRole` | `'primary'` | Featured-icon tint. |
The glyph inside should keep its default size (it inherits `--icon-size`, which
scales with the metric `size`): drop a bare `<DollarSign />` in, not
`<DollarSign size="sm" />`.
### `<Metrics.Chart>` (sparkline)
| Prop | Type | Default | Description |
| -------- | ----------- | -------------- | --------------------------------------------- |
| `data` | `Row[]` | — | Series rows (forwarded to `<Sparkline>`). |
| `x` `y` | `string` | — | Accessor keys for the x / single y series. |
| `color` | `ColorRole` | delta's intent | Line/area colour; defaults to the trend's valence. |
| `placement` | `'full' \| 'below' \| 'right'` | `'full'` | `full` breaks out to the card width past the icon (Untitled look); `below` stays in the content column; `right` pins it beside the value (dashboard-tile look). |
| `area` | `boolean` | `true` | Fill under the line. |
| `height` | `number` | `40` | Sparkline height in px (scales with `size`). |
`<Metrics.Value>` takes `value` (string/number, drives `live`) + children.
`<Metrics.Label>` / `<Metrics.Description>` / `<Metrics.Actions>` take `children`
(Actions is a slot — compose `<Link>` / `<Button>` / `<DropdownMenu>` inside).
## Sema events
| Event | Family | Verb | Target | Intent | When |
| ---------------------- | -------- | ------ | ------- | ------------------- | ------------------------------------------------- |
| `signal-notify-update` | `signal` | notify | `value` | `fromProp` (Delta's valence, default `neutral`) | `live` is on AND `Metrics.Value`'s `value` changes. |
`sequence: 'post'`, `a11ySemantic.requiresLiveRegion`, `expression:
'family-default'` (the `signal` family base drives sound/haptic; eidos
materializes the flash from `data-event-*`).
## Comparison
| Capability | Chakra | Tremor | Untitled UI | Flowbite | Radix/Ark/MUI | **Metrics** |
| ---------------------------------------------- | :----: | :----: | :---------: | :------: | :-----------: | :---------: |
| First-class metric (not compose-it-yourself) | ✅ | ✅ | ✅ | ⚠️ html | ❌ | ✅ |
| Label / value / description | ✅ | ✅ | ✅ | ✅ | manual | ✅ |
| Change delta (arrow + value + period) | ✅ | ✅ | ✅ | ✅ | manual | ✅ |
| **Direction ⟂ valence ("down is good")** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Theme + a11y colour (HCM / contrast / P3) | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Featured icon (variant × colour) | manual | ✅ | ✅ | ✅ | manual | ✅ |
| Trend sparkline | ❌ | ✅ | ✅ | ⚠️ | ❌ | ✅ (compose `Sparkline`) |
| Progress / radial gauge to goal | ❌ | manual | ✅ | ⚠️ | ❌ | ✅ (`Progress`/`Gauge` compose `Meter`) |
| Loading / empty states | manual | manual | manual | manual | manual | ✅ (compose `Skeleton`) |
| Actions row (link / dropdown) | ❌ | manual | ✅ | ❌ | manual | ✅ (compose `Link`/`Button`/`DropdownMenu`) |
| Locale-formatted value | manual | manual | manual | manual | manual | ✅ (compose `FormatNumber`) |
| **Perceptual + announced live update** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Clickable / drill-down | ❌ | ❌ | ⚠️ | ❌ | manual | ✅ (compose `<Card interactive>`) |
| Tokenized recipe · RTL-logical · sized | partial | ❌ | ❌ | ❌ | — | ✅ |
## Loading + empty states
No new parts — compose existing primitives:
- **Loading** — drop [`<Skeleton>`](../skeleton/) placeholders in place of the
parts (a `shape="circle"` for the icon, `shape="text"` for the label, a rect
for the value). Skeleton carries `role="status"` + `aria-busy`.
- **Empty** — render the metric with an em-dash value and a "No data yet"
`Metrics.Description` (and a `neutral` featured icon).
## Out of scope (v2 roadmap)
- **Multi-metric group** — Untitled lays several metrics in a row with vertical
dividers. Achievable today by composition (`<Flex>` + `<Separator orientation="vertical">`
around N `<Metrics>`); a dedicated `MetricsGroup` wrapper could add the dividers
+ responsive wrap. _Est. ~40 lines._
- **`format` passthrough on `Metrics.Value`** — route the raw `value` through the
format engine directly instead of composing `<FormatNumber>` in children.
_Est. ~30 lines._
## Audit notes
`component:audit` classifies Metrics as _interactive_ because it declares an
event, and flags two checks that **do not apply** to a surfaceless display
primitive:
- **`R-1.5` (no `:focus-visible`)** — Metrics has no focusable element of its
own. Interactivity (focus + keyboard) is delegated to `<Card interactive>` by
composition. N/A.
- **`A-1.4` (no APG url)** — Metrics is not a WAI-ARIA interactive pattern; it is
a display block with a programmatic `signal`. N/A.
- **`E-2.2` (css not in `eidos/index.css`)** — Metrics is code-split (it
self-imports `metrics.css` from `metrics.svelte`), like `card` / `badge` /
`toggle`. Adding it to the aggregate would double-load. N/A by the index.css
contract.

@ -0,0 +1,35 @@
import { getContext, setContext } from 'svelte';
import type { MetricsIntent, MetricsSize } from './types';
/**
* Context the root `<Metrics>` provides to its parts. It wires the opt-in live
* signal AND shares the delta's resolved valence:
*
* - `<Metrics.Delta>` publishes its resolved `intent` (so an update ping
* carries the right valence, and `<Metrics.Chart>` can tint the sparkline
* to match the trend).
* - `<Metrics.Value>` reports a value change so the root can fire
* `signal-notify-update` (flash + sound/haptic + live-region announce).
*
* When `live` is off, `notifyUpdate` is an inert no-op. Sizing is NOT carried
* here — it cascades through the recipe's `--_metrics-*` custom properties from
* `[data-metrics][data-size]`, like Card.
*/
export interface MetricsContext {
/** Resolved root size — sub-parts (Delta, Chart) scale to match it. */
readonly size: MetricsSize;
/** The delta's current resolved intent (`neutral` when there is no delta). */
readonly intent: MetricsIntent;
/** A `<Metrics.Delta>` publishes its resolved intent. */
setIntent(intent: MetricsIntent): void;
/** A `<Metrics.Value>` reports a live value change (no-op when `live` is off). */
notifyUpdate(el: HTMLElement | null, text: string): void;
}
const KEY = Symbol('uix.metrics');
export const setMetricsContext = (ctx: MetricsContext): void => {
setContext(KEY, ctx);
};
export const getMetricsContext = (): MetricsContext | undefined => getContext(KEY);

@ -0,0 +1,81 @@
// Metrics — a metric / KPI readout (label + value + change delta, with optional
// featured icon, trend sparkline and actions row).
//
// import { Metrics } from '$uix/eidos/components/metrics';
//
// <Metrics live>
// <Metrics.Icon variant="soft" color="primary"><DollarSign /></Metrics.Icon>
// <Metrics.Label>Total revenue</Metrics.Label>
// <Metrics.Value value={72540}>$72,540</Metrics.Value>
// <Metrics.Delta trend="up" value="12%" />
// <Metrics.Chart data={series} x="day" y="amount" />
// <Metrics.Description>vs. last month</Metrics.Description>
// <Metrics.Actions><Link href="/report">View report</Link></Metrics.Actions>
// </Metrics>
//
// Surfaceless — wrap in <Card> for the tile look. The Delta decouples direction
// (arrow) from valence (intent colour); `live` turns value changes into a
// perceptual + announced signal. Covers the Untitled-UI Simple / Icon / Chart
// families, each optionally with Actions.
import MetricsComponent from './metrics.svelte';
import Icon from './metrics-icon.svelte';
import Label from './metrics-label.svelte';
import Value from './metrics-value.svelte';
import Delta from './metrics-delta.svelte';
import Trend from './metrics-trend.svelte';
import Chart from './metrics-chart.svelte';
import Progress from './metrics-progress.svelte';
import Gauge from './metrics-gauge.svelte';
import Actions from './metrics-actions.svelte';
import Description from './metrics-description.svelte';
type MetricsNamespace = typeof MetricsComponent & {
Icon: typeof Icon;
Label: typeof Label;
Value: typeof Value;
Delta: typeof Delta;
Trend: typeof Trend;
Chart: typeof Chart;
Progress: typeof Progress;
Gauge: typeof Gauge;
Actions: typeof Actions;
Description: typeof Description;
};
const Metrics = MetricsComponent as MetricsNamespace;
Metrics.Icon = Icon;
Metrics.Label = Label;
Metrics.Value = Value;
Metrics.Delta = Delta;
Metrics.Trend = Trend;
Metrics.Chart = Chart;
Metrics.Progress = Progress;
Metrics.Gauge = Gauge;
Metrics.Actions = Actions;
Metrics.Description = Description;
export { Metrics };
export default Metrics;
export type {
MetricsProps,
MetricsLabelProps,
MetricsValueProps,
MetricsDeltaProps,
MetricsTrendProps,
MetricsIconProps,
MetricsChartProps,
MetricsProgressProps,
MetricsGaugeProps,
MetricsActionsProps,
MetricsDescriptionProps,
MetricsSize,
MetricsAlign,
MetricsLayout,
MetricsTrend,
MetricsIntent,
MetricsDeltaVariant,
MetricsDeltaSize,
MetricsIconVariant,
MetricsIconShape,
MetricsChartPlacement
} from './types';

@ -0,0 +1,13 @@
/**
* Imperative idlangref constants for the Metrics component. The Delta builds an
* accessible name from these (the arrow glyph is `aria-hidden`), so a screen
* reader hears "Increased by 12%" instead of "▲ 12%".
*
* Defaults for these paths ship in `src/uix/langs/components/metrics.ts` under
* `components.metrics.*` and are merged by `ActiveUix`.
*/
export const METRICS_LANGS = {
TREND_UP: '#?components.metrics.trend-up|Increased by {{value}}',
TREND_DOWN: '#?components.metrics.trend-down|Decreased by {{value}}',
TREND_FLAT: '#?components.metrics.trend-flat|No change'
} as const;

@ -0,0 +1,14 @@
<script lang="ts">
/**
* Actions part — a row at the bottom for a "View report" link or a dropdown.
* A slot: compose `<Link>` / `<Button>` / `<DropdownMenu>` inside. The recipe
* owns the divider + spacing only.
*/
import type { MetricsActionsProps } from './types';
let { children, ...rest }: MetricsActionsProps = $props();
</script>
<div data-metrics-actions="" {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,32 @@
<script lang="ts">
/**
* Chart part — a trend sparkline. Composes the framework `<Sparkline>` (chart
* layer). The line/area colour defaults to the delta's resolved intent (the
* trend tints the chart), falling back to `primary` when there is no delta.
* The recipe owns only the slot spacing.
*/
import { Sparkline } from '$uix/eidos/components/chart';
import { getMetricsContext } from './context.svelte';
import type { MetricsChartProps } from './types';
let {
color,
height,
placement = 'full',
class: className,
style,
...sparkline
}: MetricsChartProps = $props();
const ctx = getMetricsContext();
const resolvedColor = $derived(
color ?? (ctx && ctx.intent !== 'neutral' ? ctx.intent : 'primary')
);
// Sparkline pixel height scales with the metric size (no SVG height token).
const HEIGHT_BY_SIZE = { xs: 32, sm: 40, md: 48, lg: 56, xl: 72, xxl: 96 } as const;
const resolvedHeight = $derived(height ?? HEIGHT_BY_SIZE[ctx?.size ?? 'md']);
</script>
<div data-metrics-chart="" data-placement={placement} class={className} {style}>
<Sparkline {...sparkline} color={resolvedColor} height={resolvedHeight} />
</div>

@ -0,0 +1,84 @@
<script lang="ts">
/**
* Delta part — the change indicator. Composes the framework `<Badge>` (the
* coloured chip) + an arrow `<Icon>` (the direction). Direction and valence
* are decoupled on purpose: the arrow follows `trend`; the colour follows
* the resolved `intent` (derived from `trend` vs `goodTrend`, or overridden).
*
* The chip's `aria-label` gives a screen-reader name ("Increased by 12%")
* since the arrow is decorative.
*/
import { Badge } from '$uix/eidos/components/badge';
import { TrendingUp, TrendingDown, Minus } from '$uix/eidos/components/icon';
import { ActiveEidos } from '$uix/eidos';
import { getMetricsContext } from './context.svelte';
import { METRICS_LANGS } from './langs';
import type { MetricsDeltaProps, MetricsIntent } from './types';
let {
trend,
value,
goodTrend = 'up',
intent,
variant = 'soft',
size,
class: className,
style,
children
}: MetricsDeltaProps = $props();
const eidos = ActiveEidos.require();
const ctx = getMetricsContext();
// The badge is a step below the value's prominence; derive from the root
// size unless the consumer pins `size`. (Badge tops out at `lg`.)
const BADGE_BY_SIZE = { xs: 'xs', sm: 'xs', md: 'sm', lg: 'sm', xl: 'md', xxl: 'lg' } as const;
const resolvedSize = $derived(size ?? BADGE_BY_SIZE[ctx?.size ?? 'md']);
// Direction → valence, unless the consumer pins `intent`. A "good" move is
// affirmative; the opposite reads as risk; flat is neutral.
const resolvedIntent = $derived<MetricsIntent>(
intent ?? (trend === 'flat' ? 'neutral' : trend === goodTrend ? 'affirm' : 'risk')
);
// Publish to the root so the live signal carries this valence and the chart
// can tint to match. No loop: nothing the Delta reads depends on it.
$effect(() => {
ctx?.setIntent(resolvedIntent);
});
const valueText = $derived(value === undefined ? '' : String(value));
const ariaLabel = $derived(
trend === 'up'
? eidos.langs.t(METRICS_LANGS.TREND_UP, { value: valueText })
: trend === 'down'
? eidos.langs.t(METRICS_LANGS.TREND_DOWN, { value: valueText })
: eidos.langs.ts(METRICS_LANGS.TREND_FLAT)
);
</script>
<Badge
color={resolvedIntent}
{variant}
size={resolvedSize}
class={className}
{style}
data-metrics-delta=""
data-trend={trend}
aria-label={ariaLabel}
>
{#snippet icon()}
{#if trend === 'up'}
<TrendingUp size="1em" />
{:else if trend === 'down'}
<TrendingDown size="1em" />
{:else}
<Minus size="1em" />
{/if}
{/snippet}
{#if children}
{@render children()}
{:else if value !== undefined}
{value}
{/if}
</Badge>

@ -0,0 +1,10 @@
<script lang="ts">
/** Description part — period / context under the value. */
import type { MetricsDescriptionProps } from './types';
let { children, ...rest }: MetricsDescriptionProps = $props();
</script>
<div data-metrics-description="" {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,39 @@
<script lang="ts">
/**
* Gauge part — a radial goal gauge. Composes `<Meter shape="circular">`
* (role="meter", owns the ARIA value/range + the ring) and overlays a centred
* value — the rounded percent by default, or `children`. The circular Meter
* is `display:grid; place-items:center`, so the overlay centres for free.
*/
import { Meter } from '$uix/eidos/components/meter';
import { getMetricsContext } from './context.svelte';
import type { MetricsGaugeProps } from './types';
let {
value,
max = 100,
min = 0,
low,
high,
optimum,
ariaLabel,
children,
...rest
}: MetricsGaugeProps = $props();
const ctx = getMetricsContext();
const METER_SIZE = { xs: 'xs', sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', xxl: 'xl' } as const;
const meterSize = $derived(METER_SIZE[ctx?.size ?? 'md']);
const pct = $derived(
max === min ? 0 : Math.round(((value - min) / (max - min)) * 100)
);
</script>
<div data-metrics-gauge="" {...rest}>
<Meter shape="circular" {value} {min} {max} {low} {high} {optimum} size={meterSize} aria-label={ariaLabel}>
<Meter.Indicator />
<span data-metrics-gauge-value="">
{#if children}{@render children()}{:else}{pct}%{/if}
</span>
</Meter>
</div>

@ -0,0 +1,34 @@
<script lang="ts">
/**
* Icon part — a leading FEATURED icon. `plain` renders the bare glyph;
* `soft`/`solid`/`outline` wrap it in a tinted container (the Untitled-UI
* "featured icon" look). `shape` picks the container silhouette — a soft
* rounded-square by default. Decorative (`aria-hidden`) — the Label names it.
*
* The container is a FIXED soft square (`--metrics-icon-box-{size}`, 40px at
* md / 48px at lg) and the glyph centres inside via flex; glyph size scales
* with the root `size` via the recipe cascade, so drop a bare `<DollarSign />`
* in (it inherits `--icon-size`), not `<DollarSign size="sm" />`.
*/
import { COLOR_ROLES } from '$uix/eidos/lib/config-types';
import type { MetricsIconProps } from './types';
let { variant = 'soft', shape = 'rounded', color = 'primary', children, ...rest }: MetricsIconProps =
$props();
// Only canonical roles tint the container; anything else falls back to neutral.
const resolvedColor = $derived(
(COLOR_ROLES as readonly string[]).includes(color) ? color : 'neutral'
);
</script>
<span
data-metrics-icon=""
data-variant={variant}
data-shape={shape}
data-color={variant === 'plain' ? undefined : resolvedColor}
aria-hidden="true"
{...rest}
>
{@render children?.()}
</span>

@ -0,0 +1,10 @@
<script lang="ts">
/** Label part — the metric's name. */
import type { MetricsLabelProps } from './types';
let { children, ...rest }: MetricsLabelProps = $props();
</script>
<div data-metrics-label="" {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,36 @@
<script lang="ts">
/**
* Progress part — a linear goal bar (value toward a target). Composes the
* framework `<Meter>` (role="meter"), which owns the ARIA value/range and the
* fill. Optional `children` render as a caption under the bar.
*/
import { Meter } from '$uix/eidos/components/meter';
import { getMetricsContext } from './context.svelte';
import type { MetricsProgressProps } from './types';
let {
value,
max = 100,
min = 0,
low,
high,
optimum,
ariaLabel,
children,
...rest
}: MetricsProgressProps = $props();
const ctx = getMetricsContext();
// Meter caps at `xl`; the metric's `xxl` maps down to it.
const METER_SIZE = { xs: 'xs', sm: 'sm', md: 'md', lg: 'lg', xl: 'xl', xxl: 'xl' } as const;
const meterSize = $derived(METER_SIZE[ctx?.size ?? 'md']);
</script>
<div data-metrics-progress="" {...rest}>
<Meter {value} {min} {max} {low} {high} {optimum} size={meterSize} aria-label={ariaLabel}>
<Meter.Indicator />
</Meter>
{#if children}
<div data-metrics-progress-caption="">{@render children()}</div>
{/if}
</div>

@ -0,0 +1,14 @@
<script lang="ts">
/**
* Trend part — a thin row that puts the Delta pill and the comparison caption
* on one shared baseline (the canonical `[↑12%] vs last month` row) instead of
* stacking them. Compose `<Metrics.Delta>` + `<Metrics.Description>` inside.
*/
import type { MetricsTrendProps } from './types';
let { children, ...rest }: MetricsTrendProps = $props();
</script>
<div data-metrics-trend="" {...rest}>
{@render children?.()}
</div>

@ -0,0 +1,41 @@
<script lang="ts">
/**
* Value part — the headline figure (tabular numerals). When the root is in
* `live` mode, a change to `value` reports to the context so the root can
* fire `signal-notify-update` on this element (the flash target).
*/
import { getMetricsContext } from './context.svelte';
import type { MetricsValueProps } from './types';
let { value, children, ...rest }: MetricsValueProps = $props();
const ctx = getMetricsContext();
let el = $state<HTMLElement | null>(null);
// Non-reactive memo of the last value — comparing detects a real change
// without the effect re-subscribing to its own write. Seeded on first run
// (not from the prop at init, which would only capture the initial value).
let prev: string | number | undefined;
let firstRun = true;
$effect(() => {
const next = value;
if (firstRun) {
firstRun = false;
prev = next;
return;
}
if (next !== prev) {
prev = next;
if (next !== undefined) ctx?.notifyUpdate(el, String(next));
}
});
</script>
<div bind:this={el} data-metrics-value="" {...rest}>
{#if children}
{@render children()}
{:else if value !== undefined}
{value}
{/if}
</div>

@ -0,0 +1,388 @@
/*
* Metrics — a metric / KPI readout. SURFACELESS: layout + typography only;
* for the tile look wrap it in <Card>. Covers the Untitled-UI Simple / Icon /
* Chart families, each optionally with an Actions row.
*
* - data-size: xs | sm | md | lg | xl | xxl (whole-block scale)
* - data-align: start | center
* - data-layout: icon-start | stack (featured icon left vs. stacked on top)
* - data-live (boolean) — the value flashes on `signal-notify-update`
*
* Type hierarchy (the look): the VALUE is the only large element — rebased ~2
* steps UP the canonical scale so the small-label → big-number jump reads as a
* KPI. Label + description sit on the small body steps; the delta is a compact
* pill; the featured icon is a fixed-box soft square. A theme retunes any
* `--metrics-*` token. Private `--_metrics-*` vars carry the size cascade.
*/
[data-metrics] {
--_metrics-gap: var(--metrics-gap-md);
--_metrics-icon-gap: var(--metrics-icon-gap-md);
--_metrics-icon-box: var(--metrics-icon-box-md);
--_metrics-label-font-size: var(--metrics-label-font-size-md);
--_metrics-value-font-size: var(--metrics-value-font-size-md);
--_metrics-description-font-size: var(--metrics-description-font-size-md);
display: flex;
flex-direction: column;
gap: var(--_metrics-gap);
align-items: flex-start;
min-width: 0;
}
[data-metrics][data-align='center'] {
text-align: center;
align-items: center;
}
/* ── Icon-start layout: featured icon top-left, content stacked to its right.
* Media-object pattern — the icon is taken out of flow (absolute, top-left)
* and the content column is padded to clear it. This keeps the content's
* vertical rhythm intact (NO row inflation, NO extra height) regardless of
* icon size. `min-block-size` guards the rare case of content shorter than
* the icon. `:has` guard keeps a no-icon metric a plain column. ── */
[data-metrics][data-layout='icon-start']:has([data-metrics-icon]) {
position: relative;
padding-inline-start: calc(var(--_metrics-icon-box) + var(--_metrics-icon-gap));
min-block-size: var(--_metrics-icon-box);
}
[data-metrics][data-layout='icon-start']:has([data-metrics-icon]) [data-metrics-icon] {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
}
/* Stack layout: the featured icon sits on top — give it ~reference breathing
* room before the label (the column gap alone is too tight under a 48px icon). */
[data-metrics][data-layout='stack'] [data-metrics-icon]:not([data-variant='plain']) {
margin-block-end: var(--space-2);
}
/* ── Size scale (value 1:1 with the global type scale; md is the base) ──── */
[data-metrics][data-size='xs'] {
--_metrics-gap: var(--metrics-gap-xs);
--_metrics-icon-gap: var(--metrics-icon-gap-xs);
--_metrics-icon-box: var(--metrics-icon-box-xs);
--_metrics-label-font-size: var(--metrics-label-font-size-xs);
--_metrics-value-font-size: var(--metrics-value-font-size-xs);
--_metrics-description-font-size: var(--metrics-description-font-size-xs);
}
[data-metrics][data-size='sm'] {
--_metrics-gap: var(--metrics-gap-sm);
--_metrics-icon-gap: var(--metrics-icon-gap-sm);
--_metrics-icon-box: var(--metrics-icon-box-sm);
--_metrics-label-font-size: var(--metrics-label-font-size-sm);
--_metrics-value-font-size: var(--metrics-value-font-size-sm);
--_metrics-description-font-size: var(--metrics-description-font-size-sm);
}
[data-metrics][data-size='lg'] {
--_metrics-gap: var(--metrics-gap-lg);
--_metrics-icon-gap: var(--metrics-icon-gap-lg);
--_metrics-icon-box: var(--metrics-icon-box-lg);
--_metrics-label-font-size: var(--metrics-label-font-size-lg);
--_metrics-value-font-size: var(--metrics-value-font-size-lg);
--_metrics-description-font-size: var(--metrics-description-font-size-lg);
}
[data-metrics][data-size='xl'] {
--_metrics-gap: var(--metrics-gap-xl);
--_metrics-icon-gap: var(--metrics-icon-gap-xl);
--_metrics-icon-box: var(--metrics-icon-box-xl);
--_metrics-label-font-size: var(--metrics-label-font-size-xl);
--_metrics-value-font-size: var(--metrics-value-font-size-xl);
--_metrics-description-font-size: var(--metrics-description-font-size-xl);
}
[data-metrics][data-size='xxl'] {
--_metrics-gap: var(--metrics-gap-xxl);
--_metrics-icon-gap: var(--metrics-icon-gap-xxl);
--_metrics-icon-box: var(--metrics-icon-box-xxl);
--_metrics-label-font-size: var(--metrics-label-font-size-xxl);
--_metrics-value-font-size: var(--metrics-value-font-size-xxl);
--_metrics-description-font-size: var(--metrics-description-font-size-xxl);
}
/* ── Label ───────────────────────────────────────────────────────────── */
[data-metrics-label] {
font-family: var(--metrics-label-font-family);
font-size: var(--_metrics-label-font-size);
font-weight: var(--metrics-label-font-weight);
letter-spacing: var(--metrics-label-letter-spacing);
line-height: var(--metrics-label-line-height);
color: var(--metrics-label-color);
}
/* ── Value ───────────────────────────────────────────────────────────── */
[data-metrics-value] {
font-family: var(--metrics-value-font-family);
font-size: var(--_metrics-value-font-size);
font-weight: var(--metrics-value-font-weight);
line-height: var(--metrics-value-line-height);
letter-spacing: var(--metrics-value-letter-spacing);
/* tabular + lining numerals so digits align as values change */
font-feature-settings: var(--metrics-value-font-feature);
color: var(--metrics-value-color);
/* Room for the live flash highlight without shifting layout. */
border-radius: var(--metrics-flash-radius);
padding-inline: var(--metrics-flash-inset);
margin-inline: calc(-1 * var(--metrics-flash-inset));
/* Default flash tint; overridden per stamped signal intent below. */
--_metrics-flash-tint: var(--color-neutral-track);
}
/* ── Live-update flash (data-event-* stamped by sema during the hold) ──── */
[data-metrics-value][data-event-family='signal'][data-event-phase='active'] {
animation: metrics-flash var(--metrics-flash-duration) var(--metrics-flash-ease);
}
@keyframes metrics-flash {
0% {
background-color: transparent;
}
25% {
background-color: var(--_metrics-flash-tint);
}
100% {
background-color: transparent;
}
}
[data-metrics-value][data-event-intent='affirm'] {
--_metrics-flash-tint: var(--color-affirm-track);
}
[data-metrics-value][data-event-intent='fulfill'] {
--_metrics-flash-tint: var(--color-fulfill-track);
}
[data-metrics-value][data-event-intent='risk'] {
--_metrics-flash-tint: var(--color-risk-track);
}
[data-metrics-value][data-event-intent='threat'] {
--_metrics-flash-tint: var(--color-threat-track);
}
[data-metrics-value][data-event-intent='loss'] {
--_metrics-flash-tint: var(--color-loss-track);
}
/* ── Description ─────────────────────────────────────────────────────── */
[data-metrics-description] {
font-family: var(--metrics-description-font-family);
font-size: var(--_metrics-description-font-size);
line-height: var(--metrics-description-line-height);
color: var(--metrics-description-color);
}
/* ── Trend row: delta pill + comparison caption on one shared baseline ──
* Group <Metrics.Delta> + <Metrics.Description> here for the canonical
* `[↑12%] vs last month` row (reference look) instead of stacking them. ── */
[data-metrics-trend] {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
/* Sit a touch further from the value than the label does — the label hugs
* the value as one unit, the trend reads as the next beat (reference rhythm). */
margin-block-start: var(--space-1-5);
}
[data-metrics-trend] [data-metrics-description] {
margin-block-start: 0;
}
[data-metrics][data-align='center'] [data-metrics-trend] {
justify-content: center;
}
/* ── Trend chart (sparkline) slot ────────────────────────────────────── */
[data-metrics-chart] {
inline-size: 100%;
margin-block-start: var(--metrics-chart-margin-block-start);
}
/* full (default) — break out past the featured-icon column to the card width */
[data-metrics][data-layout='icon-start']:has([data-metrics-icon])
[data-metrics-chart][data-placement='full'] {
margin-inline-start: calc(-1 * (var(--_metrics-icon-box) + var(--_metrics-icon-gap)));
inline-size: calc(100% + var(--_metrics-icon-box) + var(--_metrics-icon-gap));
}
/* right — pin to the right edge, vertically centred beside the content */
[data-metrics]:has([data-metrics-chart][data-placement='right']) {
position: relative;
padding-inline-end: calc(var(--metrics-chart-right-width) + var(--_metrics-icon-gap));
}
[data-metrics-chart][data-placement='right'] {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
inline-size: var(--metrics-chart-right-width);
margin-block-start: 0;
display: flex;
align-items: center;
}
/* ── Actions row ─────────────────────────────────────────────────────── */
[data-metrics-actions] {
display: flex;
align-items: center;
gap: var(--metrics-actions-gap);
inline-size: 100%;
margin-block-start: var(--metrics-actions-margin-block-start);
padding-block-start: var(--metrics-actions-padding-block-start);
border-block-start: var(--border-width) solid var(--metrics-actions-border-color);
}
/* ── Progress (linear goal bar) + Gauge (radial) — compose <Meter> ─────── */
[data-metrics-progress] {
display: flex;
flex-direction: column;
gap: var(--metrics-progress-gap);
inline-size: 100%;
margin-block-start: var(--metrics-progress-margin-block-start);
}
[data-metrics-progress-caption] {
font-size: var(--_metrics-description-font-size);
line-height: var(--metrics-description-line-height);
color: var(--metrics-description-color);
}
[data-metrics-gauge] {
display: inline-flex;
}
[data-metrics-gauge-value] {
font-size: var(--metrics-gauge-value-font-size);
font-weight: var(--metrics-gauge-value-font-weight);
color: var(--metrics-gauge-value-color);
font-variant-numeric: tabular-nums;
line-height: 1;
}
/* ── Featured icon (plain glyph · soft / solid / outline × shape × colour) ── */
[data-metrics-icon] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: inherit;
/* Defaults for the no-data-color case; per-colour overrides below. */
--_mi-track: var(--metrics-icon-bg);
--_mi-solid: var(--color-neutral-solid);
--_mi-text: var(--metrics-icon-color);
--_mi-contrast: var(--color-neutral-contrast);
--_mi-border: var(--color-neutral-border);
}
/* Glyph size scales with the root size (icon is a descendant). */
[data-metrics][data-size='xs'] [data-metrics-icon] {
--icon-size: var(--icon-size-xs);
}
[data-metrics][data-size='sm'] [data-metrics-icon] {
--icon-size: var(--icon-size-sm);
}
[data-metrics][data-size='md'] [data-metrics-icon] {
--icon-size: var(--icon-size-md);
}
[data-metrics][data-size='lg'] [data-metrics-icon] {
--icon-size: var(--icon-size-lg);
}
[data-metrics][data-size='xl'] [data-metrics-icon] {
--icon-size: var(--icon-size-xl);
}
[data-metrics][data-size='xxl'] [data-metrics-icon] {
--icon-size: var(--icon-size-xxl);
}
/* Featured container (everything except `plain`) — a fixed soft square; the
* glyph centres inside via the inline-flex above. Box is decoupled from the
* type scale (the `--metrics-icon-box-*` token), so it never drifts. */
[data-metrics-icon]:not([data-variant='plain']) {
inline-size: var(--_metrics-icon-box);
block-size: var(--_metrics-icon-box);
}
/* Shape → radius (from canonical tokens). */
[data-metrics-icon][data-shape='circle'] {
border-radius: var(--radius-full);
}
[data-metrics-icon][data-shape='rounded'] {
border-radius: var(--radius-lg);
}
[data-metrics-icon][data-shape='square'] {
border-radius: var(--radius-md);
}
[data-metrics-icon][data-variant='soft'] {
background: var(--_mi-track);
color: var(--_mi-text);
}
[data-metrics-icon][data-variant='solid'] {
background: var(--_mi-solid);
color: var(--_mi-contrast);
}
[data-metrics-icon][data-variant='outline'] {
background: transparent;
border: var(--metrics-icon-border-width) solid var(--_mi-border);
color: var(--_mi-text);
}
/* Per-colour palette (one rule each; the variant rules above read these). */
[data-metrics-icon][data-color='primary'] {
--_mi-track: var(--color-primary-track);
--_mi-solid: var(--color-primary-solid);
--_mi-text: var(--color-primary-text);
--_mi-contrast: var(--color-primary-contrast);
--_mi-border: var(--color-primary-border);
}
[data-metrics-icon][data-color='secondary'] {
--_mi-track: var(--color-secondary-track);
--_mi-solid: var(--color-secondary-solid);
--_mi-text: var(--color-secondary-text);
--_mi-contrast: var(--color-secondary-contrast);
--_mi-border: var(--color-secondary-border);
}
[data-metrics-icon][data-color='neutral'] {
--_mi-track: var(--color-neutral-track);
--_mi-solid: var(--color-neutral-solid);
--_mi-text: var(--color-neutral-text);
--_mi-contrast: var(--color-neutral-contrast);
--_mi-border: var(--color-neutral-border);
}
[data-metrics-icon][data-color='affirm'] {
--_mi-track: var(--color-affirm-track);
--_mi-solid: var(--color-affirm-solid);
--_mi-text: var(--color-affirm-text);
--_mi-contrast: var(--color-affirm-contrast);
--_mi-border: var(--color-affirm-border);
}
[data-metrics-icon][data-color='fulfill'] {
--_mi-track: var(--color-fulfill-track);
--_mi-solid: var(--color-fulfill-solid);
--_mi-text: var(--color-fulfill-text);
--_mi-contrast: var(--color-fulfill-contrast);
--_mi-border: var(--color-fulfill-border);
}
[data-metrics-icon][data-color='risk'] {
--_mi-track: var(--color-risk-track);
--_mi-solid: var(--color-risk-solid);
--_mi-text: var(--color-risk-text);
--_mi-contrast: var(--color-risk-contrast);
--_mi-border: var(--color-risk-border);
}
[data-metrics-icon][data-color='threat'] {
--_mi-track: var(--color-threat-track);
--_mi-solid: var(--color-threat-solid);
--_mi-text: var(--color-threat-text);
--_mi-contrast: var(--color-threat-contrast);
--_mi-border: var(--color-threat-border);
}
[data-metrics-icon][data-color='loss'] {
--_mi-track: var(--color-loss-track);
--_mi-solid: var(--color-loss-solid);
--_mi-text: var(--color-loss-text);
--_mi-contrast: var(--color-loss-contrast);
--_mi-border: var(--color-loss-border);
}

@ -0,0 +1,77 @@
<script lang="ts">
/**
* Eidos `<Metrics>` — a metric / KPI readout.
*
* <Metrics>
* <Metrics.Icon variant="soft" color="primary"><DollarSign /></Metrics.Icon>
* <Metrics.Label>Total revenue</Metrics.Label>
* <Metrics.Value value={72540}>$72,540</Metrics.Value>
* <Metrics.Delta trend="up" value="12%" />
* <Metrics.Chart {data} x="day" y="amount" />
* <Metrics.Description>vs. last month</Metrics.Description>
* <Metrics.Actions><Link href="...">View report</Link></Metrics.Actions>
* </Metrics>
*
* Surfaceless content block (compose `<Card>` for the tile look). The Delta
* decouples direction from valence; `live` turns value changes into a
* `signal-notify-update` (flash + sound/haptic + live-region announce).
*/
import './metrics.css';
import { ActiveEidos } from '$uix/eidos';
import { createMetricsRuntime } from '$soma/components/metrics';
import { setMetricsContext, type MetricsContext } from './context.svelte';
import type { MetricsProps, MetricsIntent } from './types';
let {
size = 'md',
align = 'start',
layout = 'icon-start',
live = false,
children,
...rest
}: MetricsProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// Live-signal bridge. The Delta publishes its intent; the Value reports a
// change. The runtime resolves the signal's intent from the `intent` source.
let currentIntent = $state<MetricsIntent>('neutral');
const runtime = createMetricsRuntime({
dom: eidos.dom,
eventEngine: eidos.uix?.events,
announce: eidos.uix ? (m, p) => eidos.uix!.announce(m, p) : undefined,
props: { intent: () => currentIntent }
});
const ctx: MetricsContext = {
get size() {
return resolvedSize;
},
get intent() {
return currentIntent;
},
setIntent(intent) {
currentIntent = intent;
},
notifyUpdate(el, text) {
if (!live) return;
void runtime.trigger('signal-notify-update', {
fallbackTarget: el ?? undefined,
message: text
});
}
};
setMetricsContext(ctx);
</script>
<div
data-metrics=""
data-size={resolvedSize}
data-align={align}
data-layout={layout}
data-live={live ? '' : undefined}
{...rest}
>
{@render children?.()}
</div>

@ -0,0 +1,245 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ChipVariant, ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { SparklineProps } from '$uix/eidos/components/chart';
/**
* Metrics size. Drives the WHOLE block — value, label, description, the delta
* badge and the featured icon all scale with it. The value is rebased ~2 steps
* UP the canonical type scale (so the small-label → big-number jump reads as a
* KPI); label/description stay on the small body steps. All token-driven — a
* theme can retune any `--metrics-*` token. Six steps from a compact inline
* metric (`xs`) up to a hero dashboard KPI (`xxl`); `md` is the default.
*/
export type MetricsSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'>;
/** Text/cross-axis alignment of the metric block. @default 'start' */
export type MetricsAlign = 'start' | 'center';
/**
* The delta's direction — drives the ARROW glyph, decoupled from the colour.
* `flat` shows a dash and reads as neutral.
*/
export type MetricsTrend = 'up' | 'down' | 'flat';
/**
* The delta's evaluative reading — drives the COLOUR via the canonical
* intent→colour tokens. A subset of `ColorRole`. Usually derived from
* `trend` + `goodTrend`, but can be set explicitly for full control.
*/
export type MetricsIntent = 'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss';
/** Delta chip treatment. Reuses the canonical `ChipVariant`. @default 'soft' */
export type MetricsDeltaVariant = ChipVariant;
/** Delta sizing — the composed `<Badge>` scale. @default 'sm' */
export type MetricsDeltaSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg'>;
/** Featured-icon treatment. `plain` = bare glyph, no container. @default 'soft' */
export type MetricsIconVariant = 'plain' | 'soft' | 'solid' | 'outline';
/** Featured-icon silhouette. @default 'rounded' */
export type MetricsIconShape = 'circle' | 'rounded' | 'square';
/**
* Trend-chart placement.
* - `full` (default) — spans the whole card width, breaking out past the
* featured icon's column (the Untitled "Chart" look).
* - `below` — sits in the content column, aligned under the value.
* - `right` — pinned to the right, vertically centred beside the value (the
* "dashboard tile" look).
*/
export type MetricsChartPlacement = 'below' | 'right' | 'full';
/**
* Block layout. `icon-start` places the featured icon in a left column with the
* content stacked to its right (the Untitled-UI "Icon" family); `stack` flows
* every part in a single vertical column (icon on top when present).
* @default 'icon-start'
*/
export type MetricsLayout = 'stack' | 'icon-start';
/**
* Props for `<Metrics>` — a metric / KPI readout.
*
* Surfaceless by design: for the "KPI card" look wrap it in `<Card>`. For an
* icon-beside-content layout, compose with the framework layout primitives
* (`<Group>` / `<Flex>`) rather than re-rolling a row here.
*/
export type MetricsProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** Sizing scale (value type + spacing). @default 'md' */
size?: ResponsiveProp<MetricsSize>;
/** Cross-axis alignment of the block. @default 'start' */
align?: MetricsAlign;
/**
* Block layout — `icon-start` (featured icon in a left column, content
* stacked right) or `stack` (single vertical column). Only affects the
* arrangement when a `<Metrics.Icon>` is present.
* @default 'icon-start'
*/
layout?: MetricsLayout;
/**
* Opt into the live KPI: when on, a change to `<Metrics.Value>`'s `value`
* fires `signal-notify-update` — the value flashes, sema pings (if the
* sound/haptic channels are enabled), and the new value is announced to
* the live region. Off by default = pure display, zero runtime cost.
* @default false
*/
live?: boolean;
children?: Snippet;
};
/** Label part — the metric's name. Renders as `<div>`. */
export type MetricsLabelProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
children?: Snippet;
};
/**
* Value part — the headline figure (tabular numerals).
*
* Pass `value` for the live-update detection + the default render; or pass
* `children` for custom content (e.g. a composed `<FormatNumber>`). When both
* are present, `children` renders and `value` still drives live detection.
* `live` (on the root) requires `value` to detect a change.
*/
export type MetricsValueProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** The metric value. Rendered when no `children`; drives live detection. */
value?: string | number;
children?: Snippet;
};
/**
* Delta part — the change indicator. Composes `<Badge>` + an arrow `<Icon>`.
*
* Direction (`trend`) and valence (`intent`) are decoupled: the arrow follows
* `trend`; the colour follows the resolved `intent`. By default the intent is
* derived from `trend` vs `goodTrend` — set `goodTrend="down"` for metrics
* where falling is good (churn, cost, latency). Override `intent` for full
* control.
*/
export type MetricsDeltaProps = {
/** Change direction — drives the arrow glyph. */
trend: MetricsTrend;
/** The change magnitude text (e.g. `"12%"`). Rendered + used for the a11y label. */
value?: string | number;
/**
* Which direction is "good". When `trend === goodTrend` the delta reads
* affirmative; otherwise it reads as risk. Ignored when `intent` is set.
* @default 'up'
*/
goodTrend?: 'up' | 'down';
/** Explicit evaluative colour, overriding the `trend`/`goodTrend` derivation. */
intent?: MetricsIntent;
/** Chip treatment. @default 'soft' */
variant?: MetricsDeltaVariant;
/** Chip size. @default 'sm' */
size?: MetricsDeltaSize;
/** Extra class names, forwarded to the composed Badge. */
class?: string;
/** Extra inline style, forwarded to the composed Badge. */
style?: string;
/** Custom content, overriding the default `value` render. */
children?: Snippet;
};
/**
* Icon part — an optional leading FEATURED icon. `plain` renders a bare glyph;
* `soft`/`solid`/`outline` wrap it in a tinted container (the Untitled-UI
* "featured icon" look). Decorative (`aria-hidden`) — the Label names the metric.
* Drop any framework icon inside: `<Metrics.Icon variant="soft" color="primary"><DollarSign /></Metrics.Icon>`.
*/
export type MetricsIconProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
/** Container treatment. @default 'soft' */
variant?: MetricsIconVariant;
/** Container silhouette. @default 'circle' */
shape?: MetricsIconShape;
/** Featured-icon tint — a canonical `ColorRole`. @default 'primary' */
color?: ColorRole;
children?: Snippet;
};
/**
* Chart part — a trend sparkline. Composes the framework `<Sparkline>`; the
* colour defaults to the delta's resolved intent (the trend tints the line).
* Pass `data` + the `x`/`y` keys, like `<Sparkline>`.
*/
export type MetricsChartProps = Omit<SparklineProps, 'color'> & {
/** Line/area colour. Defaults to the delta's resolved intent. */
color?: ColorRole;
/** Where the sparkline sits relative to the content. @default 'full' */
placement?: MetricsChartPlacement;
/** Extra class names on the slot. */
class?: string;
/** Extra inline style on the slot. */
style?: string;
};
/**
* Trend part — a thin row grouping the Delta pill + the comparison caption on
* one shared baseline (`[↑12%] vs last month`). Compose `<Metrics.Delta>` +
* `<Metrics.Description>` inside instead of stacking them in the column.
*/
export type MetricsTrendProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
children?: Snippet;
};
/**
* Progress part — a linear goal bar (value toward a target). Composes the
* framework `<Meter>` (role="meter"), which owns the ARIA value/range. Optional
* `children` render as a caption under the bar (e.g. "72% to goal").
*/
export type MetricsProgressProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** Current value. */
value: number;
/** Goal / range maximum. @default 100 */
max?: number;
/** Range minimum. @default 0 */
min?: number;
/** Optional zone thresholds, forwarded to `<Meter>`. */
low?: number;
/** Optional zone thresholds, forwarded to `<Meter>`. */
high?: number;
/** Optional zone thresholds, forwarded to `<Meter>`. */
optimum?: number;
/** Accessible name for the bar. */
ariaLabel?: string;
/** Caption under the bar (e.g. "72% to goal"). */
children?: Snippet;
};
/**
* Gauge part — a radial goal gauge. Composes `<Meter shape="circular">` + a
* centred value overlay. The Meter owns the ARIA value/range. `children`
* override the centre label (defaults to the rounded percent).
*/
export type MetricsGaugeProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** Current value. */
value: number;
/** Goal / range maximum. @default 100 */
max?: number;
/** Range minimum. @default 0 */
min?: number;
/** Optional zone thresholds, forwarded to `<Meter>`. */
low?: number;
/** Optional zone thresholds, forwarded to `<Meter>`. */
high?: number;
/** Optional zone thresholds, forwarded to `<Meter>`. */
optimum?: number;
/** Accessible name for the gauge. */
ariaLabel?: string;
/** Centre label; defaults to the rounded percent. */
children?: Snippet;
};
/**
* Actions part — a row at the bottom for a "View report" link or a dropdown.
* A slot: compose `<Link>` / `<Button>` / `<DropdownMenu>` inside.
*/
export type MetricsActionsProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
children?: Snippet;
};
/** Description part — period / context under the value. Renders as `<div>`. */
export type MetricsDescriptionProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
children?: Snippet;
};

@ -9,8 +9,11 @@
* Animation: optional shimmer wave via background-position keyframes.
*/
[data-skeleton] {
--_skeleton-bg: color-mix(in srgb, var(--color-neutral-track) 60%, transparent);
--_skeleton-highlight: color-mix(in srgb, var(--color-neutral-track) 100%, white 10%);
/* Mix toward `neutral-solid` (which inverts luminance across modes) so the
* placeholder stays a visible subtle grey in BOTH light and dark — the bare
* `neutral-track` is ~white in light mode and vanishes on a light surface. */
--_skeleton-bg: color-mix(in oklab, var(--color-neutral-track), var(--color-neutral-solid) 16%);
--_skeleton-highlight: color-mix(in oklab, var(--color-neutral-track), var(--color-neutral-solid) 7%);
--_skeleton-size: 1rem;
--_skeleton-radius: var(--radius-md);
--_skeleton-duration: 1.4s;

@ -2684,17 +2684,17 @@
--metrics-gap-xl: var(--space-2);
--metrics-gap-xxl: var(--space-3);
--metrics-icon-gap-xs: var(--space-2);
--metrics-icon-gap-sm: var(--space-2-5);
--metrics-icon-gap-md: var(--space-3);
--metrics-icon-gap-lg: var(--space-3-5);
--metrics-icon-gap-sm: var(--space-3);
--metrics-icon-gap-md: var(--space-4);
--metrics-icon-gap-lg: var(--space-4);
--metrics-icon-gap-xl: var(--space-4);
--metrics-icon-gap-xxl: var(--space-5);
--metrics-icon-padding-xs: var(--space-1-5);
--metrics-icon-padding-sm: var(--space-2);
--metrics-icon-padding-md: var(--space-2);
--metrics-icon-padding-lg: var(--space-2-5);
--metrics-icon-padding-xl: var(--space-3);
--metrics-icon-padding-xxl: var(--space-4);
--metrics-icon-box-xs: 1.75rem;
--metrics-icon-box-sm: 2rem;
--metrics-icon-box-md: 2.5rem;
--metrics-icon-box-lg: 3rem;
--metrics-icon-box-xl: 3.5rem;
--metrics-icon-box-xxl: 4.5rem;
--metrics-icon-border-width: var(--border-width);
--metrics-icon-bg: var(--color-surface-muted);
--metrics-icon-color: var(--color-content-secondary);
@ -2710,12 +2710,12 @@
--metrics-label-line-height: var(--leading-ui);
--metrics-label-letter-spacing: var(--tracking-normal);
--metrics-value-font-family: var(--font-ui);
--metrics-value-font-size-xs: var(--font-size-xs);
--metrics-value-font-size-sm: var(--font-size-sm);
--metrics-value-font-size-md: var(--font-size-md);
--metrics-value-font-size-lg: var(--font-size-lg);
--metrics-value-font-size-xl: var(--font-size-xl);
--metrics-value-font-size-xxl: var(--font-size-xxl);
--metrics-value-font-size-xs: var(--font-size-md);
--metrics-value-font-size-sm: var(--font-size-lg);
--metrics-value-font-size-md: var(--font-size-xl);
--metrics-value-font-size-lg: var(--font-size-xxl);
--metrics-value-font-size-xl: var(--font-size-xxxl);
--metrics-value-font-size-xxl: var(--font-size-xxxl);
--metrics-value-font-weight: var(--font-weight-semibold);
--metrics-value-color: var(--color-content-primary);
--metrics-value-line-height: var(--leading-tight);
@ -2732,6 +2732,11 @@
--metrics-description-line-height: var(--leading-normal);
--metrics-chart-margin-block-start: var(--space-3);
--metrics-chart-right-width: 7rem;
--metrics-progress-margin-block-start: var(--space-3);
--metrics-progress-gap: var(--space-1-5);
--metrics-gauge-value-font-size: var(--font-size-sm);
--metrics-gauge-value-font-weight: var(--font-weight-semibold);
--metrics-gauge-value-color: var(--color-content-primary);
--metrics-actions-gap: var(--space-3);
--metrics-actions-margin-block-start: var(--space-3);
--metrics-actions-padding-block-start: var(--space-3);

@ -3073,17 +3073,20 @@ export const THEME_BASE_RECIPE_TOKENS = {
// canonical tokens in CSS; padding + glyph size scale with the metric
// size; the shape drives the radius from canonical `--radius-*`). ──
'icon-gap-xs': 'var(--space-2)',
'icon-gap-sm': 'var(--space-2-5)',
'icon-gap-md': 'var(--space-3)',
'icon-gap-lg': 'var(--space-3-5)',
'icon-gap-sm': 'var(--space-3)',
'icon-gap-md': 'var(--space-4)',
'icon-gap-lg': 'var(--space-4)',
'icon-gap-xl': 'var(--space-4)',
'icon-gap-xxl': 'var(--space-5)',
'icon-padding-xs': 'var(--space-1-5)',
'icon-padding-sm': 'var(--space-2)',
'icon-padding-md': 'var(--space-2)',
'icon-padding-lg': 'var(--space-2-5)',
'icon-padding-xl': 'var(--space-3)',
'icon-padding-xxl': 'var(--space-4)',
// Explicit featured-icon BOX (off the type scale on purpose — the Untitled
// "featured icon" is a fixed 48px square at lg; the glyph centres inside via
// flex). Decoupled from font changes so it never drifts. Justified off-ladder.
'icon-box-xs': '1.75rem',
'icon-box-sm': '2rem',
'icon-box-md': '2.5rem',
'icon-box-lg': '3rem',
'icon-box-xl': '3.5rem',
'icon-box-xxl': '4.5rem',
'icon-border-width': 'var(--border-width)',
'icon-bg': 'var(--color-surface-muted)',
'icon-color': 'var(--color-content-secondary)',
@ -3100,13 +3103,17 @@ export const THEME_BASE_RECIPE_TOKENS = {
'label-line-height': 'var(--leading-ui)',
'label-letter-spacing': 'var(--tracking-normal)',
// ── Value (headline figure, tabular numerals) ──
// REBASED UP the type scale: the value is the only large element, ~2 steps
// above the label, so the small-label → big-number jump (the canonical KPI
// hierarchy) actually happens. NOT 1:1 with the size name — that collapsed
// the hierarchy (md value == label). Still token-driven + theme-overridable.
'value-font-family': 'var(--font-ui)',
'value-font-size-xs': 'var(--font-size-xs)',
'value-font-size-sm': 'var(--font-size-sm)',
'value-font-size-md': 'var(--font-size-md)',
'value-font-size-lg': 'var(--font-size-lg)',
'value-font-size-xl': 'var(--font-size-xl)',
'value-font-size-xxl': 'var(--font-size-xxl)',
'value-font-size-xs': 'var(--font-size-md)',
'value-font-size-sm': 'var(--font-size-lg)',
'value-font-size-md': 'var(--font-size-xl)',
'value-font-size-lg': 'var(--font-size-xxl)',
'value-font-size-xl': 'var(--font-size-xxxl)',
'value-font-size-xxl': 'var(--font-size-xxxl)',
'value-font-weight': 'var(--font-weight-semibold)',
'value-color': 'var(--color-content-primary)',
'value-line-height': 'var(--leading-tight)',
@ -3124,6 +3131,13 @@ export const THEME_BASE_RECIPE_TOKENS = {
'description-line-height': 'var(--leading-normal)',
// ── Trend chart (sparkline) slot ──
'chart-margin-block-start': 'var(--space-3)',
'chart-right-width': '7rem',
// ── Progress / gauge to goal (compose <Meter>) ──
'progress-margin-block-start': 'var(--space-3)',
'progress-gap': 'var(--space-1-5)',
'gauge-value-font-size': 'var(--font-size-sm)',
'gauge-value-font-weight': 'var(--font-weight-semibold)',
'gauge-value-color': 'var(--color-content-primary)',
// ── Actions row (View report link / dropdown) ──
'actions-gap': 'var(--space-3)',
'actions-margin-block-start': 'var(--space-3)',

@ -0,0 +1,128 @@
---
title: Pendiente — exponer la paleta completa en el `color` de componentes + demos
type: backlog
audience: human + agent
status: pending
created: 2026-06-26
---
# Pendiente — exponer la paleta completa (31 escalas) en el `color` de componentes + demos
> Detectado mientras se construía el componente **Metrics** (2026-06-26). Se
> aparca como sprint propio para no desviar esa sesión. Este documento es
> auto-contenido: una sesión futura puede arrancar desde aquí.
## El problema (observación del usuario)
> "Los demos solo presentan los colores de los roles/intents y no los demás. El
> sistema admite toda una paleta. Es un error del layout y de todas las demos."
Concreto: el control de color de cada demo (p. ej. `icon color` en Metrics)
solo ofrece **8 valores** (`primary · secondary · neutral · affirm · fulfill ·
risk · threat · loss`). El sistema tiene **31 escalas** generadas
(`--scale-{name}-*`: amber, blue, bronze, brown, crimson, cyan, gold, grass,
gray, indigo, iris, jade, lime, mauve, mint, olive, orange, pink, plum, purple,
red, ruby, sage, sand, sky, slate, teal, tomato, violet, yellow + …) que **no**
son elegibles desde la API de componente.
## Esto NO es una decisión nueva — es doctrina ya cerrada, sub-implementada
El modelo de color (cerrado 2026-06-02) **ya prescribe** lo que el usuario pide:
- **THEMING.md §4** (líneas ~446-447): *"Si `intent === 'neutral'`, `color`
(hierarchy override) puede aplicar. Si `intent` es evaluativo, el `intent`
GANA y `color` se ignora."* — el modelo del usuario: cuando el intent es
neutral, el color es libre (jerarquía o paleta).
- **THEMING.md §25.5** (Override por componente): *"Cualquier componente acepta
`color="..."` (**cualquier escala de la paleta**) → la cascada `_accent-*` del
recipe remapea sus tokens a esa escala para esa instancia. Equivalente a
`<Button color="grass">` de Radix."*
- **COLOR_ENGINE_RFC.md** (líneas 200, 569, 583): `<Button color="grass">` se
cita explícitamente como feature soportada; las escalas "siguen disponibles"
como override `data-color`.
- **9 roles canónicos** = `primary · secondary · tertiary · neutral · affirm ·
fulfill · risk · threat · loss` (THEMING §4 / §25). Las 31 escalas son la
paleta donante, **directamente usable** (`var(--scale-{name}-{step})`).
El modelo del usuario, 1:1 con la doctrina:
- **Eje evaluativo (`intent`)**: `neutral · affirm · fulfill · risk · threat ·
loss`. Valenciado → el color lo fija la semántica (no se elige).
- **Cuando `intent = neutral`**: el `color` es libre → **jerarquía**
(`primary · secondary · tertiary`) **o cualquier escala de la paleta** (31).
## El hueco real (implementación)
1. **Tipo `ColorRole`** (`src/uix/eidos/lib/types.ts`):
`'primary' | 'secondary' | Intent` = **8** valores. Falta:
- **`tertiary`** — existe en `HIERARCHY_COLOR_ROLES` (`config-types.ts`) y se
emite como `--color-tertiary-*`, pero está **RESERVED** (sin consumidor;
ver comentario en `lib/themes/base.ts:29`). No está en el tipo de componente.
- **Las 31 escalas** — no hay un tipo `PaletteScale` ni se aceptan como `color`.
2. **Generación per-color** (`render-css.ts`): emite bloques
`[data-{c}][data-color='X']` solo para los **9 roles** (`COLOR_ROLES`). Una
instancia `color="teal"` **no tiene bloque** → cae al default.
3. **Mecanismo parcial existente**:
- `select` tiene cascada `_accent-*` (TSC `parts: ['trigger','content']`).
- `card` tiene escape `customColor` (`card.svelte:69` — cualquier color no-rol
→ `--card-color-custom`, derivado con `color-mix` desde **un** color, no una
escala de 12 pasos).
- No es universal ni tipado. **15 componentes** declaran `data-color` (TSC v2.2
cobertura universal) pero solo sobre los 9 roles.
## Plan (cerrar el hueco por el camino canónico §25.5)
1. **Tipos** (`lib/types.ts`):
- Añadir `tertiary` a la jerarquía expuesta.
- Nuevo `PaletteScale` (las 31 nombres de escala, derivado de la fuente única
de escalas — NO hardcodear; ver `radix-scales.ts` / `themes/base.ts`).
- El `color` de componente = `HierarchyColorRole(primary·secondary·tertiary)`
∪ `Intent` ∪ `PaletteScale`. Cada componente sigue narrowando con `Extract<>`
a su subset (la doctrina del subset per-componente, THEMING §4).
- Mantener la regla §4: intent valenciado gana; neutral libera el `color`.
2. **Generador** (`render-css.ts`): para los componentes con cascada `_accent-*`,
emitir `[data-{c}][data-color='{escala}']` por las 31 escalas, derivando los 9
slots de `--scale-{escala}-{step}` con `DEFAULT_COLOR_ROLE_SLOT_STEPS`
(track=1 · element=3 · hover=4 · active=5 · border=6/7 · solid=9 · solidHover=10
· text=11 · contrast=on-solid). Misma fórmula que ya deriva los roles.
3. **Decisión de bundle (pre-rollout)**: per-componente (31 × ~9 slots × 15
componentes ≈ miles de declaraciones, purgeables con `eidos:purge`) **vs** un
mecanismo `--accent-{slot}` **compartido** scoped a `[data-color='X']` que los
componentes lean (más DRY, menos bytes, pero cambia el patrón per-componente
`--{c}-palette-*`). Evaluar en el piloto.
4. **Demos**: el control de color de cada demo muestra **jerarquía
(primary·secondary·tertiary) + paleta completa**, gated en `intent = neutral`
(cuando hay eje intent). Es "el error del layout y de todas las demos" que
señaló el usuario — aplica al patrón de demo, no solo a Metrics.
## Recomendación de ejecución
- **Pilotar en `Badge`** (componente de color más simple; lo usa
`Metrics.Delta`): exponer jerarquía+paleta, verificar visualmente (Chrome MCP),
decidir per-componente vs `--accent-*` compartido, y **luego** rollout a los 15
componentes con `data-color` + sus demos.
- Respetar los guardarraíles: los **9 roles siguen siendo canon** (§19); las
escalas son el **override per-instancia** (Radix `color="grass"`); los **intents
siguen valenciados** (§4 — el color no redefine el intent, la capa **sema** lleva
la valencia/activación).
## Verificación al cerrar el sprint
- `npm run generate:eidos-css` + `npm run check` (0 errores nuevos).
- `recipe-css-contract.test.ts` verde (sin reservas fantasma — ver
`eidos/README.md`: "No hay reservas fantasma ni paletas que el componente no
pueda activar desde su API real" — exponer la paleta CIERRA esa deuda con
`tertiary`).
- `morfo:check` de los demos tocados.
- Probe visual en Chrome de un `color="teal"` / `color="plum"` por componente
piloto, en light + dark.
## Referencias
- `THEMING.md` §4 (9 roles + regla intent/color), §19 (variants/roles = canon),
§25 (modelo de color — paleta + roles + intents; §25.5 override por componente).
- `COLOR_MODEL_RFC.md` (modelo resuelto), `COLOR_ENGINE_RFC.md` (capa física +
`color="grass"`), `TSC.md` (`scope: 'color:X'` → `[data-{c}][data-color='X']`).
- `lib/types.ts` (`ColorRole`), `lib/config-types.ts` (`HIERARCHY_COLOR_ROLES`,
`CANONICAL_INTENT_SCALES`), `lib/themes/base.ts` (mapa rol→escala, `tertiary`
RESERVED), `lib/render-css.ts` (`COLOR_ROLES` loop, `DEFAULT_COLOR_ROLE_SLOT_STEPS`).

@ -0,0 +1,226 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Metrics — a metric / KPI readout (label + value + change delta, with an
* optional leading featured icon, trend sparkline and actions row).
*
* Eidos-native primitive (no soma provider), modeled on `card` / `badge`. It is
* a SURFACELESS content block — for the "KPI card" look the consumer wraps it in
* `<Card>` (composition, no surface logic duplicated here). It covers the three
* Untitled-UI metric families — Simple, Icon, Chart — each optionally with an
* Actions row.
*
* Anatomy (all parts optional except Provider):
*
* Metrics (group)
* ├─ Icon leading featured icon (soft / solid / outline chip)
* ├─ Label metric name ("Total revenue")
* ├─ Value headline figure (tabular numerals)
* ├─ Delta change indicator — composes <Badge> + <Icon> (arrow)
* ├─ Chart trend sparkline — composes the <Sparkline> primitive
* ├─ Actions actions row — "View report" link / dropdown
* └─ Description period / context ("vs. last month")
*
* The differentiator vs Chakra/Tremor/Untitled lives in the **Delta**:
* direction (`data-trend` = up/down/flat → the arrow) is decoupled from valence
* (`intent` → the colour). With `goodTrend="down"` a metric where falling is
* good (churn, cost, latency) paints ▼ in an affirmative tone — the colour comes
* from the canonical intent→colour tokens, so it inherits light/dark,
* forced-colors (HCM), prefers-contrast and wide-gamut for free.
*
* One doctrinal event: `signal-notify-update` — fired ONLY when the opt-in
* `live` mode is on and the value changes (real-time dashboards). It targets
* the Value (eidos flashes it during the hold), carries the delta's intent,
* and requests a live-region announcement for screen-reader parity. By
* default (`live` off) the component is pure display with zero runtime.
*/
export const metricsMorfo = {
name: 'Metrics',
kebab: 'metrics',
scope: ['sema', 'eidos'],
expression: 'family-default',
texts: {
label: '#?components.metrics.label|Metric'
},
events: [
{
name: 'signal-notify-update',
semantic: {
family: 'signal',
verb: 'notify',
target: v.partRef('value'),
// 'post' — the value has already changed; the signal acknowledges it.
sequence: 'post',
// The change carries the delta's evaluative reading. `neutral` when
// there is no delta (a plain "value updated" ping).
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']
}
},
// Screen readers can't see the flash — announce the new value.
a11ySemantic: { requiresLiveRegion: true }
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
{
// Marks the opt-in live mode. The eidos root writes it AND boots
// the soma-runtime bridge that fires `signal-notify-update`.
attr: 'data-live',
value: v.literal(''),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'live' }
}
],
aria: []
},
{
name: 'Icon',
kebab: 'icon',
archetype: 'indicator',
kind: 'public',
defaultElement: 'span',
optional: true,
// Decorative — the Label carries the metric's name for the a11y tree.
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
name: 'Label',
kebab: 'label',
archetype: 'label',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// The headline figure. Target of `signal-notify-update`; eidos flashes
// `[data-metrics-value][data-event-family='signal']` during the hold.
name: 'Value',
kebab: 'value',
archetype: 'indicator',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Change indicator. Renders a composed <Badge> + arrow <Icon>.
// `data-trend` is the DIRECTION (drives the arrow); the colour comes
// from the resolved `intent` (Badge `data-color`), decoupled on purpose.
name: 'Delta',
kebab: 'delta',
archetype: 'indicator',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [
{
attr: 'data-trend',
values: ['up', 'down', 'flat'],
value: v.propRef('trend')
}
],
aria: []
},
{
// Trend row — groups the Delta pill + the comparison caption on one
// shared baseline (the canonical `[↑12%] vs last month` row). Layout slot.
name: 'Trend',
kebab: 'trend',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Trend sparkline. Composes the framework <Sparkline> (chart layer);
// the recipe owns only the slot box. Decorative — the value + delta
// carry the readable data.
name: 'Chart',
kebab: 'chart',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// Linear goal bar — composes the framework <Meter> (role="meter").
// The Meter inside owns the ARIA value/range; this slot is layout.
name: 'Progress',
kebab: 'progress',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Radial goal gauge — composes <Meter shape="circular"> + a centred
// value overlay. The Meter owns the ARIA value/range.
name: 'Gauge',
kebab: 'gauge',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Internal: caption under the Progress bar (e.g. "72% to goal").
name: 'ProgressCaption',
kebab: 'progress-caption',
kind: 'private',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// Internal: centred value overlay inside the Gauge ring.
name: 'GaugeValue',
kebab: 'gauge-value',
kind: 'private',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{
// Actions row — "View report" link / dropdown trigger. A slot the
// consumer fills with composed <Link> / <Button> / <DropdownMenu>.
name: 'Actions',
kebab: 'actions',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Description',
kebab: 'description',
archetype: 'description',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,8 @@
// Metrics soma layer — the headless behaviour bridge for the live KPI.
//
// import { createMetricsRuntime } from '$soma/components/metrics';
//
// Metrics is an eidos-native display primitive; this layer exists only so the
// eidos root can fire `signal-notify-update` (sound / haptic / live-region)
// when the opt-in `live` value changes. No provider/state class.
export { createMetricsRuntime, metricsMorfo } from './metrics.svelte';

@ -0,0 +1,21 @@
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { metricsMorfo } from '$uix/morfo/components/metrics';
/**
* The Metrics soma runtime — the headless behaviour bridge for the (opt-in)
* live KPI. Metrics is otherwise pure display, so there is no provider/state
* class here: the eidos root owns the render and only needs to fire one
* perceptual signal when a live value changes.
*
* runtime.trigger('signal-notify-update', { fallbackTarget, message })
*
* dispatches the `signal` family (sound / haptic) and — via the morfo's
* `a11ySemantic.requiresLiveRegion` — announces `message` to the live region.
* The delta's resolved `intent` reaches the signal through the `intent` prop
* source the eidos root registers.
*/
export function createMetricsRuntime(sources: SomaRuntimeSources): SomaRuntime {
return createSomaRuntime(metricsMorfo, sources);
}
export { metricsMorfo };

@ -0,0 +1,989 @@
<script lang="ts">
import { Metrics } from '$uix/eidos/components/metrics';
import type {
MetricsSize,
MetricsAlign,
MetricsLayout,
MetricsTrend,
MetricsIntent,
MetricsDeltaVariant,
MetricsIconVariant,
MetricsIconShape,
MetricsChartPlacement
} from '$uix/eidos/components/metrics';
import { Card } from '$uix/eidos/components/card';
import { Button } from '$uix/eidos/components/button';
import { Skeleton } from '$uix/eidos/components/skeleton';
import { Separator } from '$uix/eidos/components/separator';
import { Link } from '$uix/eidos/components/link';
import {
DollarSign,
Database,
Users,
Activity,
ShoppingCart,
Clock,
Eye,
ArrowUpRight
} from '$uix/eidos/components/icon';
import type { ColorRole } from '$uix/eidos/lib/types';
import { compileMorfo } from '$uix/morfo';
import { metricsMorfo } from '@/uix/morfo/components/metrics';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'gallery' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Root props ─────────────────────────────────────────────────────────
let size = $state<MetricsSize>('lg');
let align = $state<MetricsAlign>('start');
let layout = $state<MetricsLayout>('icon-start');
let live = $state(true);
// ── Value ──────────────────────────────────────────────────────────────
let value = $state(72540);
const display = $derived('$' + value.toLocaleString('en-US'));
// ── Delta ──────────────────────────────────────────────────────────────
let trend = $state<MetricsTrend>('up');
let goodTrend = $state<'up' | 'down'>('up');
let intentProp = $state<MetricsIntent | 'auto'>('auto');
let deltaVariant = $state<MetricsDeltaVariant>('soft');
let deltaValue = $state('12%');
// ── Icon (featured) + chart + actions + content ──────────────────────────
let showIcon = $state(true);
let iconVariant = $state<MetricsIconVariant>('soft');
let iconShape = $state<MetricsIconShape>('rounded');
let iconColor = $state<ColorRole>('primary');
let showChart = $state(true);
let chartPlacement = $state<MetricsChartPlacement>('full');
let showActions = $state(false);
let labelText = $state('Total revenue');
let descText = $state('vs. last month');
// Sample sparkline series (12 points, gently trending up).
const series = [
{ day: 1, amount: 38 },
{ day: 2, amount: 41 },
{ day: 3, amount: 39 },
{ day: 4, amount: 47 },
{ day: 5, amount: 52 },
{ day: 6, amount: 49 },
{ day: 7, amount: 58 },
{ day: 8, amount: 63 },
{ day: 9, amount: 60 },
{ day: 10, amount: 71 },
{ day: 11, amount: 76 },
{ day: 12, amount: 84 }
];
// A gently falling series — for "down is good" KPIs (churn, latency, cost).
const seriesDown = [
{ day: 1, amount: 41 },
{ day: 2, amount: 38 },
{ day: 3, amount: 39 },
{ day: 4, amount: 33 },
{ day: 5, amount: 30 },
{ day: 6, amount: 31 },
{ day: 7, amount: 26 },
{ day: 8, amount: 24 },
{ day: 9, amount: 22 },
{ day: 10, amount: 19 },
{ day: 11, amount: 18 },
{ day: 12, amount: 14 }
];
const sizes: MetricsSize[] = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'];
const aligns: MetricsAlign[] = ['start', 'center'];
const layouts: MetricsLayout[] = ['icon-start', 'stack'];
const iconShapes: MetricsIconShape[] = ['circle', 'rounded', 'square'];
const trends: MetricsTrend[] = ['up', 'down', 'flat'];
const goodTrends = ['up', 'down'] as const;
const intentOptions = ['auto', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const;
const deltaVariants: MetricsDeltaVariant[] = ['soft', 'solid', 'outline', 'ghost'];
const iconVariants: MetricsIconVariant[] = ['plain', 'soft', 'solid', 'outline'];
const chartPlacements: MetricsChartPlacement[] = ['full', 'below', 'right'];
const iconColors: ColorRole[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
// Mirror the Delta's intent derivation so the trace + ▶ play match the chip.
const resolvedIntent = $derived<MetricsIntent>(
intentProp !== 'auto'
? intentProp
: trend === 'flat'
? 'neutral'
: trend === goodTrend
? 'affirm'
: 'risk'
);
const deltaIntentProp = $derived<MetricsIntent | undefined>(
intentProp === 'auto' ? undefined : intentProp
);
const compiled = compileMorfo(metricsMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $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')}`;
}
function simulate(): void {
const step = trend === 'down' ? -1240 : trend === 'flat' ? 0 : 1240;
value = Math.max(0, value + step);
}
function playTarget(part: string): HTMLElement | null {
return (stageRef?.querySelector(`[data-metrics-${part}]`) ?? stageRef) as HTMLElement | 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 eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Metrics } from '$uix/eidos/components/metrics';",
" import { Card } from '$uix/eidos/components/card';",
" import { DollarSign } from '$uix/eidos/components/icon';",
` let value = $state(${value});`,
'</' + 'script>',
'',
'<Card variant="outline">',
' <Metrics' +
(size !== 'md' ? ` size="${size}"` : '') +
(align !== 'start' ? ` align="${align}"` : '') +
(layout !== 'icon-start' ? ` layout="${layout}"` : '') +
(live ? ' live' : '') +
'>',
showIcon &&
` <Metrics.Icon variant="${iconVariant}" shape="${iconShape}"${iconVariant !== 'plain' ? ` color="${iconColor}"` : ''}><DollarSign /></Metrics.Icon>`,
` <Metrics.Label>${labelText}</Metrics.Label>`,
' <Metrics.Value {value}>$' + '{value.toLocaleString()}</Metrics.Value>',
' <Metrics.Trend>',
' <Metrics.Delta' +
` trend="${trend}" value="${deltaValue}"` +
(goodTrend !== 'up' ? ` goodTrend="${goodTrend}"` : '') +
(deltaIntentProp ? ` intent="${deltaIntentProp}"` : '') +
(deltaVariant !== 'soft' ? ` variant="${deltaVariant}"` : '') +
' />',
descText && ` <Metrics.Description>${descText}</Metrics.Description>`,
' </Metrics.Trend>',
showChart &&
` <Metrics.Chart data={series} x="day" y="amount"${chartPlacement !== 'full' ? ` placement="${chartPlacement}"` : ''} />`,
showActions &&
' <Metrics.Actions><Button variant="ghost" size="sm">View report</Button></Metrics.Actions>',
' </Metrics>',
'</Card>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Data · Metrics</div>
<h1 data-uix-page-title>Metrics</h1>
<p data-uix-page-lede>
A metric / KPI readout — label, headline value and a change delta, with an optional featured
icon, trend sparkline and actions row. Covers the Untitled-UI <strong>Simple</strong>,
<strong>Icon</strong>
and <strong>Chart</strong> families (each ± actions). Surfaceless: compose it inside
<code>&lt;Card&gt;</code>. The Delta decouples direction (the arrow) from valence (the colour) —
set <code>goodTrend="down"</code> where falling is good. Opt into <code>live</code> and a value
change fires <code>signal-notify-update</code> (flash + sema ping + announce).
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill
><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span
>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{events.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>families</span>3</span>
<span data-uix-meta-pill><span data-uix-meta-key>intents</span>6</span>
<span data-uix-meta-pill><span data-uix-meta-key>scope</span>sema · eidos</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<Card variant="outline" style="inline-size: min(22rem, 100%);">
<Metrics {size} {align} {layout} {live}>
{#if showIcon}
<Metrics.Icon variant={iconVariant} shape={iconShape} color={iconColor}>
<DollarSign />
</Metrics.Icon>
{/if}
<Metrics.Label>{labelText}</Metrics.Label>
<Metrics.Value {value}>{display}</Metrics.Value>
<Metrics.Trend>
<Metrics.Delta
{trend}
value={deltaValue}
{goodTrend}
intent={deltaIntentProp}
variant={deltaVariant}
/>
{#if descText}<Metrics.Description>{descText}</Metrics.Description>{/if}
</Metrics.Trend>
{#if showChart}<Metrics.Chart data={series} x="day" y="amount" placement={chartPlacement} />{/if}
{#if showActions}
<Metrics.Actions><Button variant="ghost" size="sm">View report</Button></Metrics.Actions>
{/if}
</Metrics>
</Card>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>turn on <code>live</code> + press “Simulate update” to see the signal</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>intent</span>
{resolvedIntent}
<span data-uix-stage-trace-key style="margin-inline-start: var(--uix-space-2);">value</span>
{value}
</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 === 'gallery'} onclick={() => (tab = 'gallery')}
>Gallery</button
>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}
>API <span data-uix-tab-count>16</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 · {events.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>{events.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>
Controls are grouped by owner. <span data-uix-layer-badge="eidos">eidos</span>
owns layout, the delta chip, the featured icon and the chart; the <span
data-uix-layer-badge="sema">sema</span
>
signal (Sema tab) fires on a live value change. Toggle icon / chart / actions to see the
Untitled families.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · layout
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>align</span>
<span data-uix-chips role="radiogroup">
{#each aligns as a}
<button data-uix-chip data-active={align === a} onclick={() => (align = a)}>{a}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>layout <span data-uix-control-hint>icon position</span></span>
<span data-uix-chips role="radiogroup">
{#each layouts as l}
<button data-uix-chip data-active={layout === l} onclick={() => (layout = l)}>{l}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>label text</span>
<input type="text" bind:value={labelText} />
</label>
<label data-uix-control>
<span data-uix-control-label>description</span>
<input type="text" bind:value={descText} placeholder="(empty to omit)" />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · delta (direction ⟂ valence)
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>trend <span data-uix-control-hint>arrow</span></span>
<span data-uix-chips role="radiogroup">
{#each trends as t}
<button data-uix-chip data-active={trend === t} onclick={() => (trend = t)}>{t}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>goodTrend <span data-uix-control-hint>which way is good</span></span
>
<span data-uix-chips role="radiogroup">
{#each goodTrends as g}
<button data-uix-chip data-active={goodTrend === g} onclick={() => (goodTrend = g)}
>{g}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>intent <span data-uix-control-hint>auto = derived</span></span>
<span data-uix-chips role="radiogroup">
{#each intentOptions as i}
<button data-uix-chip data-active={intentProp === i} onclick={() => (intentProp = i)}
>{i}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each deltaVariants as v}
<button data-uix-chip data-active={deltaVariant === v} onclick={() => (deltaVariant = v)}
>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>delta value</span>
<input type="text" bind:value={deltaValue} />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · featured icon
</div>
<div data-uix-controls>
<label data-uix-control data-uix-control-inline>
<input type="checkbox" bind:checked={showIcon} />
<span data-uix-control-label>show icon</span>
</label>
<label data-uix-control>
<span data-uix-control-label>icon variant</span>
<span data-uix-chips role="radiogroup">
{#each iconVariants as v}
<button data-uix-chip data-active={iconVariant === v} onclick={() => (iconVariant = v)}
>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>icon shape</span>
<span data-uix-chips role="radiogroup">
{#each iconShapes as sh}
<button data-uix-chip data-active={iconShape === sh} onclick={() => (iconShape = sh)}
>{sh}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>icon color</span>
<span data-uix-chips role="radiogroup">
{#each iconColors as c}
<button data-uix-chip data-active={iconColor === c} onclick={() => (iconColor = c)}
>{c}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · chart + actions
</div>
<div data-uix-controls>
<label data-uix-control data-uix-control-inline>
<input type="checkbox" bind:checked={showChart} />
<span data-uix-control-label>show chart <span data-uix-control-hint>sparkline</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>chart placement</span>
<span data-uix-chips role="radiogroup">
{#each chartPlacements as p}
<button
data-uix-chip
data-active={chartPlacement === p}
onclick={() => (chartPlacement = p)}>{p}</button
>
{/each}
</span>
</label>
<label data-uix-control data-uix-control-inline>
<input type="checkbox" bind:checked={showActions} />
<span data-uix-control-label>show actions <span data-uix-control-hint>View report</span></span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="sema">sema</span> · live signal
</div>
<div data-uix-controls>
<label data-uix-control data-uix-control-inline>
<input type="checkbox" bind:checked={live} />
<span data-uix-control-label>live <span data-uix-control-hint>fire on value change</span></span
>
</label>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>{value}</span></span>
<input type="number" bind:value step="100" />
</label>
<label data-uix-control>
<span data-uix-control-label>&nbsp;</span>
<button data-uix-chip onclick={simulate}>Simulate update</button>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> · more variants
<span data-uix-control-hint>compose Progress · Gauge · Skeleton</span>
</div>
<div
style="display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:var(--uix-space-3);"
>
<Card variant="outline">
<Metrics size="sm">
<Metrics.Label>Quarterly target</Metrics.Label>
<Metrics.Value value={72540}>$72.5k</Metrics.Value>
<Metrics.Progress value={72540} max={100000} ariaLabel="Quarterly target"
>72% to goal</Metrics.Progress
>
</Metrics>
</Card>
<Card variant="outline">
<Metrics size="sm">
<Metrics.Label>Storage used</Metrics.Label>
<div style="display:flex; align-items:center; gap:var(--uix-space-3);">
<Metrics.Gauge value={68} max={100} ariaLabel="Storage used" />
<Metrics.Value>6.8 TB</Metrics.Value>
</div>
</Metrics>
</Card>
<Card variant="outline">
<div style="display:flex; gap:var(--uix-space-3);">
<Skeleton shape="circle" size="md" />
<div style="flex:1; display:grid; gap:var(--uix-space-2);">
<Skeleton shape="text" width="55%" />
<Skeleton width="75%" height={24} />
</div>
</div>
</Card>
<Card variant="outline">
<Metrics size="sm">
<Metrics.Icon variant="soft" color="neutral"><Database /></Metrics.Icon>
<Metrics.Label>Revenue</Metrics.Label>
<Metrics.Value>—</Metrics.Value>
<Metrics.Description>No data yet</Metrics.Description>
</Metrics>
</Card>
</div>
<div data-uix-code style="margin-top: var(--uix-space-4);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>composed Card + Metrics + Badge + Icon + Sparkline</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'gallery'}
<section data-uix-section>
<h2 data-uix-section-title>Gallery — possibilities</h2>
<p data-uix-section-intro>
Every tile is the <em>same</em> compound — `Metrics` + its parts — wrapped in
`Card`. Nothing bespoke: the icon, delta, sparkline, progress bar and gauge are
all composed framework components. Direction (the arrow) is decoupled from valence
(the colour), so "down is good" KPIs read correctly.
</p>
<!-- Dashboard row — N metrics + vertical Separator (composition) -->
<div data-uix-subsection-head>Dashboard row — metrics + vertical `Separator`</div>
<Card variant="outline">
<div class="gallery-row">
<Metrics size="sm" layout="stack" style="flex:1; min-inline-size:7rem;">
<Metrics.Label>Revenue</Metrics.Label>
<Metrics.Value value={72540}>$72.5k</Metrics.Value>
<Metrics.Delta trend="up" value="12%" />
</Metrics>
<Separator orientation="vertical" />
<Metrics size="sm" layout="stack" style="flex:1; min-inline-size:7rem;">
<Metrics.Label>Orders</Metrics.Label>
<Metrics.Value value={1240}>1,240</Metrics.Value>
<Metrics.Delta trend="up" value="8%" />
</Metrics>
<Separator orientation="vertical" />
<Metrics size="sm" layout="stack" style="flex:1; min-inline-size:7rem;">
<Metrics.Label>Churn</Metrics.Label>
<Metrics.Value value={2.1}>2.1%</Metrics.Value>
<Metrics.Delta trend="down" value="0.4pp" goodTrend="down" />
</Metrics>
</div>
</Card>
<!-- Grid of individual possibilities -->
<div class="gallery-grid">
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Label>Active users</Metrics.Label>
<Metrics.Value value={8420}>8,420</Metrics.Value>
</Metrics>
</Card>
<span class="gallery-cap">Simple — label + value</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Label>New signups</Metrics.Label>
<Metrics.Value value={1240}>1,240</Metrics.Value>
<Metrics.Delta trend="up" value="18%" />
</Metrics>
</Card>
<span class="gallery-cap">+ change delta</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Icon variant="soft" color="primary"><Users /></Metrics.Icon>
<Metrics.Label>Team members</Metrics.Label>
<Metrics.Value value={48}>48</Metrics.Value>
<Metrics.Delta trend="up" value="6" />
</Metrics>
</Card>
<span class="gallery-cap">Featured icon (stack)</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="icon-start">
<Metrics.Icon variant="soft" color="primary"><DollarSign /></Metrics.Icon>
<Metrics.Label>Total revenue</Metrics.Label>
<Metrics.Value value={72540}>$72,540</Metrics.Value>
<Metrics.Delta trend="up" value="12%" />
<Metrics.Chart data={series} x="day" y="amount" />
</Metrics>
</Card>
<span class="gallery-cap">Icon-start + full sparkline (default)</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Icon variant="soft" color="secondary"><Activity /></Metrics.Icon>
<Metrics.Label>API latency</Metrics.Label>
<Metrics.Value value={128}>128ms</Metrics.Value>
<Metrics.Delta trend="down" value="14ms" goodTrend="down" />
<Metrics.Chart data={seriesDown} x="day" y="amount" placement="below" color="affirm" />
</Metrics>
</Card>
<span class="gallery-cap">Sparkline below</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Label>Sales today</Metrics.Label>
<Metrics.Value value={3120}>3,120</Metrics.Value>
<Metrics.Delta trend="up" value="9%" />
<Metrics.Chart data={series} x="day" y="amount" placement="right" />
</Metrics>
</Card>
<span class="gallery-cap">Sparkline right</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Label>Quarterly target</Metrics.Label>
<Metrics.Value value={72540}>$72.5k</Metrics.Value>
<Metrics.Progress value={72540} max={100000} ariaLabel="Quarterly target"
>72% of $100k goal</Metrics.Progress
>
</Metrics>
</Card>
<span class="gallery-cap">Progress to goal (Meter)</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Label>Storage used</Metrics.Label>
<div style="display:flex; align-items:center; gap:var(--uix-space-3);">
<Metrics.Gauge value={68} max={100} ariaLabel="Storage used" />
<Metrics.Value>6.8 / 10 TB</Metrics.Value>
</div>
</Metrics>
</Card>
<span class="gallery-cap">Radial gauge (Meter)</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Icon variant="soft" color="affirm"><Clock /></Metrics.Icon>
<Metrics.Label>Churn rate</Metrics.Label>
<Metrics.Value value={2.1}>2.1%</Metrics.Value>
<Metrics.Trend>
<Metrics.Delta trend="down" value="0.4pp" goodTrend="down" />
<Metrics.Description>↓ is good — affirm</Metrics.Description>
</Metrics.Trend>
</Metrics>
</Card>
<span class="gallery-cap">"Down is good" — ▼ affirm</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Label>Refunds</Metrics.Label>
<Metrics.Value value={3210}>$3,210</Metrics.Value>
<Metrics.Trend>
<Metrics.Delta trend="up" value="8%" />
<Metrics.Description>↑ is bad — risk</Metrics.Description>
</Metrics.Trend>
</Metrics>
</Card>
<span class="gallery-cap">Negative — ▲ risk (default)</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack" align="center">
<Metrics.Icon variant="solid" shape="circle" color="fulfill"
><ShoppingCart /></Metrics.Icon
>
<Metrics.Label>Conversion</Metrics.Label>
<Metrics.Value value={3.8}>3.8%</Metrics.Value>
<Metrics.Delta trend="up" value="0.5pp" />
</Metrics>
</Card>
<span class="gallery-cap">Centered + solid circle icon</span>
</div>
<div class="gallery-tile">
<Card interactive variant="outline">
<Metrics size="md" layout="icon-start">
<Metrics.Icon variant="soft" color="primary"><Eye /></Metrics.Icon>
<Metrics.Label>Page views</Metrics.Label>
<Metrics.Value value={48200}>48.2k</Metrics.Value>
<Metrics.Delta trend="up" value="11%" />
<Metrics.Actions>
<Link href="#gallery">View report <ArrowUpRight /></Link>
</Metrics.Actions>
</Metrics>
</Card>
<span class="gallery-cap">Interactive card + actions</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<div style="display:flex; gap:var(--uix-space-3);">
<Skeleton shape="circle" size="md" />
<div style="flex:1; display:grid; gap:var(--uix-space-2);">
<Skeleton shape="text" width="55%" />
<Skeleton width="75%" height={24} />
<Skeleton width="40%" height={14} />
</div>
</div>
</Card>
<span class="gallery-cap">Loading — Skeleton</span>
</div>
<div class="gallery-tile">
<Card variant="outline">
<Metrics size="md" layout="stack">
<Metrics.Icon variant="soft" color="neutral"><Database /></Metrics.Icon>
<Metrics.Label>Revenue</Metrics.Label>
<Metrics.Value>—</Metrics.Value>
<Metrics.Description>No data yet</Metrics.Description>
</Metrics>
</Card>
<span class="gallery-cap">Empty state</span>
</div>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part · Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">Metrics · size</td><td class="type">{`ResponsiveProp<'xs'…'xxl'>`}</td><td class="default">'md'</td><td>Whole-block scale (6 steps); value 1:1 with the type scale, every sub-part scales. Theme-overridable token.</td></tr>
<tr><td class="name">Metrics · align</td><td class="type">'start' | 'center'</td><td class="default">'start'</td><td>Cross-axis alignment.</td></tr>
<tr><td class="name">Metrics · layout</td><td class="type">'icon-start' | 'stack'</td><td class="default">'icon-start'</td><td>Featured-icon position — left column vs. stacked on top.</td></tr>
<tr><td class="name">Metrics · live</td><td class="type">boolean</td><td class="default">false</td><td>Fire <code>signal-notify-update</code> on a Value change.</td></tr>
<tr><td class="name">Metrics.Value · value</td><td class="type">string | number</td><td class="default empty">—</td><td>The figure. Rendered when no children; drives live detection.</td></tr>
<tr><td class="name">Metrics.Delta · trend</td><td class="type">'up' | 'down' | 'flat'</td><td class="default empty">—</td><td>Direction — drives the arrow glyph.</td></tr>
<tr><td class="name">Metrics.Delta · value</td><td class="type">string | number</td><td class="default empty">—</td><td>Change magnitude; used for the a11y label.</td></tr>
<tr><td class="name">Metrics.Delta · goodTrend</td><td class="type">'up' | 'down'</td><td class="default">'up'</td><td>Which direction reads as affirmative.</td></tr>
<tr><td class="name">Metrics.Delta · intent</td><td class="type">MetricsIntent</td><td class="default empty">— (derived)</td><td>Override the trend→colour derivation.</td></tr>
<tr><td class="name">Metrics.Delta · variant</td><td class="type">'soft'|'solid'|'outline'|'ghost'</td><td class="default">'soft'</td><td>Chip treatment (composed Badge).</td></tr>
<tr><td class="name">Metrics.Icon · variant</td><td class="type">'plain'|'soft'|'solid'|'outline'</td><td class="default">'soft'</td><td>Featured-icon container treatment.</td></tr>
<tr><td class="name">Metrics.Icon · shape</td><td class="type">'circle'|'rounded'|'square'</td><td class="default">'rounded'</td><td>Featured-icon silhouette.</td></tr>
<tr><td class="name">Metrics.Chart · placement</td><td class="type">'full'|'below'|'right'</td><td class="default">'full'</td><td>Sparkline position: full-width (breaks out past the icon) · in-column · pinned right.</td></tr>
<tr><td class="name">Metrics.Icon · color</td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Featured-icon tint.</td></tr>
<tr><td class="name">Metrics.Chart · data / x / y</td><td class="type">{`Row[] / string / string`}</td><td class="default empty">—</td><td>Sparkline series + accessor keys (composed <code>&lt;Sparkline&gt;</code>).</td></tr>
<tr><td class="name">Metrics.Chart · color</td><td class="type">ColorRole</td><td class="default empty">— (delta intent)</td><td>Line/area colour; defaults to the trend's valence.</td></tr>
<tr><td class="name">Metrics.Actions</td><td class="type">Snippet</td><td class="default empty">—</td><td>Footer row — compose <code>&lt;Link&gt;</code> / <code>&lt;Button&gt;</code> / <code>&lt;DropdownMenu&gt;</code>.</td></tr>
<tr><td class="name">Metrics.Label / Description</td><td class="type">Snippet</td><td class="default empty">—</td><td>Metric name · period / context.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/metrics.ts</code>.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{metricsMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{metricsMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{metricsMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">expression</td><td class="type">"{metricsMorfo.expression}"</td></tr>
<tr><td class="name">parts.length</td><td class="default">{metricsMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Archetype</th><th>Optional</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="default">{part.archetype ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<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 metricsMorfo.events as ev}
{@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: unknown; target?: { ref?: string } }}
<tr>
<td class="name">{ev.name}</td>
<td class="type">{sem.family}</td>
<td class="default">{sem.verb ?? '—'}</td>
<td class="default">{sem.sequence ?? '—'}</td>
<td class="default">{typeof sem.intent === 'string' ? sem.intent : 'fromProp: intent'}</td>
<td class="default">{sem.target?.ref ?? '—'}</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
</h2>
<p data-uix-section-desc>
One event: <code>signal-notify-update</code> (signal · notify), fired only in
<code>live</code> mode when the Value changes. Its intent follows the Delta's resolved
valence. ▶ play emits onto the live <code>[data-metrics-value]</code> target.
</p>
<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>play</th></tr></thead>
<tbody>
{#each metricsMorfo.events as ev}
{@const sem = ev.semantic as { family: string; verb?: string; sequence?: string; intent?: unknown; target?: { ref?: string } }}
<tr>
<td class="name">{ev.name}</td>
<td class="type">{sem.family}</td>
<td class="default">{sem.verb ?? '—'}</td>
<td class="default">{sem.sequence ?? '—'}</td>
<td class="default">{resolvedIntent}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = playTarget(sem.target?.ref ?? 'value');
if (!target) return;
void uix.events?.emit({
name: ev.name,
family: sem.family as never,
target,
intent: resolvedIntent as never
});
}}>▶ 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 at <code>src/uix/eidos/components/metrics/metrics.css</code>. Surfaceless: layout
+ typography only. The Delta's colour comes from the composed Badge; the chart from the
composed Sparkline.
</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-metrics][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-metrics][data-align]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-metrics][data-live]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-metrics-label] / [data-metrics-value] / [data-metrics-description]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-metrics-delta][data-trend]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
<tr><td class="name">[data-metrics-icon][data-variant][data-color]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-metrics-chart] / [data-metrics-actions]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td></tr>
<tr><td class="name">[data-metrics-value][data-event-family='signal']</td><td><span data-uix-tag data-kind="sema">sema</span></td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
Metrics is a display block read linearly by a screen reader (label, value, then delta). The
Delta's arrow is <code>aria-hidden</code>; its accessible name comes from the trend phrase
(<code>components.metrics.trend-*</code>), so a reader hears “Increased by 12%”. The featured
icon and sparkline are <code>aria-hidden</code> (decorative). In <code>live</code> mode the new
value is announced via the shared live region. Interactive drill-down is delegated to
<code>&lt;Card interactive&gt;</code>; the Actions row composes a real <code>&lt;Link&gt;</code> /
<code>&lt;Button&gt;</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Handling</th></tr></thead>
<tbody>
<tr><td>Delta name</td><td class="type">aria-label from <code>trend-up/down/flat</code> + value</td></tr>
<tr><td>Decorative glyphs</td><td class="type">arrow, featured icon + sparkline are aria-hidden</td></tr>
<tr><td>Live updates</td><td class="type">announced to the live region (requiresLiveRegion)</td></tr>
<tr><td>Numerals</td><td class="type">tabular + lining (digits align as values change)</td></tr>
<tr><td>Reduced motion</td><td class="type">flash capped to 1ms; sound/haptic still convey the signal</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--uix-space-5);
margin-top: var(--uix-space-4);
}
.gallery-tile {
display: flex;
flex-direction: column;
gap: var(--uix-space-2);
min-inline-size: 0;
}
.gallery-cap {
font-size: 0.75rem;
color: var(--color-content-muted);
letter-spacing: 0.01em;
}
.gallery-row {
display: flex;
align-items: stretch;
gap: var(--uix-space-4);
flex-wrap: wrap;
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.