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
parent
c945ac00d5
commit
31671bb323
@ -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,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;
|
||||
};
|
||||
@ -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><Card></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> </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><Sparkline></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><Link></code> / <code><Button></code> / <code><DropdownMenu></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"><{part.defaultElement}></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><Card interactive></code>; the Actions row composes a real <code><Link></code> /
|
||||
<code><Button></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…
Reference in new issue